Genesis
12
.browserslistrc
Normal file
@@ -0,0 +1,12 @@
|
||||
# Modern browsers only - no legacy support
|
||||
# This configuration targets browsers that support ES6+ natively
|
||||
Chrome >= 91
|
||||
Firefox >= 90
|
||||
Safari >= 14
|
||||
Edge >= 91
|
||||
|
||||
# Alternative: Use this more conservative approach if you need broader support
|
||||
# last 2 Chrome versions
|
||||
# last 2 Firefox versions
|
||||
# last 2 Safari versions
|
||||
# last 2 Edge versions
|
||||
51
.eslintrc.cjs
Normal file
@@ -0,0 +1,51 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
es2022: true,
|
||||
},
|
||||
globals: {
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
],
|
||||
parser: '@babel/eslint-parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
requireConfigFile: false,
|
||||
babelOptions: {
|
||||
presets: ['@babel/preset-react']
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
'react',
|
||||
'react-hooks',
|
||||
],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'no-unused-vars': ['error', {
|
||||
varsIgnorePattern: 'React',
|
||||
ignoreRestSiblings: true,
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_'
|
||||
}],
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react/jsx-uses-react': 'error',
|
||||
'react/jsx-uses-vars': 'error',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
};
|
||||
61
.gitignore
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.cursor/
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
/public/index.prerender.html
|
||||
/public/assets/images/prod*.jpg
|
||||
/public/assets/images/cat*.jpg
|
||||
/public/prerender.css
|
||||
/public/Artikel/*
|
||||
/public/Kategorie/*
|
||||
/public/agb
|
||||
/public/batteriegesetzhinweise
|
||||
/public/datenschutz
|
||||
/public/impressum
|
||||
/public/sitemap
|
||||
/public/widerrufsrecht
|
||||
/public/robots.txt
|
||||
/public/sitemap.xml
|
||||
/public/index.prerender.html
|
||||
/public/Konfigurator
|
||||
/public/profile
|
||||
|
||||
/public/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
|
||||
.vscode
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Local configuration
|
||||
src/config.local.js
|
||||
|
||||
# Local development notes
|
||||
dev-notes.md
|
||||
dev-notes.local.md
|
||||
103
.vimrc
Normal file
@@ -0,0 +1,103 @@
|
||||
" React Shop Vim Configuration
|
||||
|
||||
" Basic settings
|
||||
set nocompatible
|
||||
set encoding=utf-8
|
||||
set number
|
||||
set ruler
|
||||
set autoindent
|
||||
set smartindent
|
||||
set expandtab
|
||||
set tabstop=2
|
||||
set shiftwidth=2
|
||||
set softtabstop=2
|
||||
set smarttab
|
||||
set backspace=indent,eol,start
|
||||
set incsearch
|
||||
set hlsearch
|
||||
set ignorecase
|
||||
set smartcase
|
||||
set showmatch
|
||||
set wildmenu
|
||||
set wildmode=list:longest,full
|
||||
set laststatus=2
|
||||
set title
|
||||
set scrolloff=3
|
||||
set sidescrolloff=5
|
||||
set wrap
|
||||
set linebreak
|
||||
set mouse=a
|
||||
set clipboard=unnamed
|
||||
set history=1000
|
||||
set undolevels=1000
|
||||
set showcmd
|
||||
set showmode
|
||||
set hidden
|
||||
set ttyfast
|
||||
set lazyredraw
|
||||
set updatetime=300
|
||||
|
||||
" JavaScript & React specific settings
|
||||
syntax enable
|
||||
filetype plugin indent on
|
||||
let g:jsx_ext_required = 0 " Allow JSX in .js files
|
||||
autocmd FileType javascript setlocal ts=2 sts=2 sw=2
|
||||
autocmd FileType javascriptreact setlocal ts=2 sts=2 sw=2
|
||||
autocmd FileType typescript setlocal ts=2 sts=2 sw=2
|
||||
autocmd FileType typescriptreact setlocal ts=2 sts=2 sw=2
|
||||
|
||||
" Better highlighting for JSX
|
||||
autocmd BufRead,BufNewFile *.jsx set filetype=javascriptreact
|
||||
autocmd BufRead,BufNewFile *.tsx set filetype=typescriptreact
|
||||
|
||||
" Set 2 space indentation for JSON files
|
||||
autocmd FileType json setlocal ts=2 sts=2 sw=2
|
||||
|
||||
" Highlight trailing whitespace
|
||||
highlight ExtraWhitespace ctermbg=red guibg=red
|
||||
match ExtraWhitespace /\s\+$/
|
||||
autocmd BufWinEnter * match ExtraWhitespace /\s\+$/
|
||||
autocmd InsertEnter * match ExtraWhitespace /\s\+\%#\@<!$/
|
||||
autocmd InsertLeave * match ExtraWhitespace /\s\+$/
|
||||
|
||||
" Strip trailing whitespace on save
|
||||
autocmd BufWritePre * :%s/\s\+$//e
|
||||
|
||||
" If using plugins with vim-plug (commented out by default)
|
||||
" Uncomment and install vim-plug to use these
|
||||
"
|
||||
" call plug#begin('~/.vim/plugged')
|
||||
"
|
||||
" " JavaScript/React plugins
|
||||
" Plug 'pangloss/vim-javascript'
|
||||
" Plug 'maxmellon/vim-jsx-pretty'
|
||||
" Plug 'leafgarland/typescript-vim'
|
||||
" Plug 'peitalin/vim-jsx-typescript'
|
||||
" Plug 'prettier/vim-prettier', { 'do': 'npm install' }
|
||||
"
|
||||
" " Useful development plugins
|
||||
" Plug 'mattn/emmet-vim'
|
||||
" Plug 'tpope/vim-surround'
|
||||
" Plug 'tpope/vim-commentary'
|
||||
" Plug 'jiangmiao/auto-pairs'
|
||||
" Plug 'neoclide/coc.nvim', {'branch': 'release'}
|
||||
" Plug 'preservim/nerdtree'
|
||||
" Plug 'airblade/vim-gitgutter'
|
||||
" Plug 'itchyny/lightline.vim'
|
||||
"
|
||||
" call plug#end()
|
||||
"
|
||||
" " Emmet settings for JSX
|
||||
" let g:user_emmet_settings = {
|
||||
" \ 'javascript' : {
|
||||
" \ 'extends' : 'jsx',
|
||||
" \ },
|
||||
" \}
|
||||
"
|
||||
" " NERDTree configuration
|
||||
" nnoremap <C-n> :NERDTreeToggle<CR>
|
||||
" let NERDTreeShowHidden=1
|
||||
|
||||
" Custom keybindings
|
||||
nnoremap <silent> <C-j> :m +1<CR>
|
||||
nnoremap <silent> <C-k> :m -2<CR>
|
||||
21
babel.config.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": {
|
||||
"browsers": [
|
||||
"Chrome >= 91",
|
||||
"Firefox >= 90",
|
||||
"Safari >= 14",
|
||||
"Edge >= 91"
|
||||
]
|
||||
},
|
||||
"modules": false,
|
||||
"useBuiltIns": false
|
||||
}
|
||||
],
|
||||
["@babel/preset-react", { "runtime": "automatic" }]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
8
createEmotionCache.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import createCache from '@emotion/cache';
|
||||
|
||||
export default function createEmotionCache() {
|
||||
return createCache({
|
||||
key: 'css',
|
||||
speedy: false // Disable speedy mode for SSR - matches working test
|
||||
});
|
||||
}
|
||||
55
eslint.config.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import js from '@eslint/js';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
import babelParser from '@babel/eslint-parser';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parser: babelParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
requireConfigFile: false,
|
||||
babelOptions: {
|
||||
presets: ['@babel/preset-react']
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooksPlugin,
|
||||
},
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'no-unused-vars': ['error', {
|
||||
varsIgnorePattern: 'React',
|
||||
ignoreRestSiblings: true,
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_'
|
||||
}],
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react/jsx-uses-react': 'error',
|
||||
'react/jsx-uses-vars': 'error',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
12492
package-lock.json
generated
Normal file
68
package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "reactshop",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "webpack serve --progress --mode development --no-open",
|
||||
"prod": "webpack serve --progress --mode production --no-client-overlay --no-client --no-web-socket-server --no-open --no-live-reload --no-hot --compress --no-devtool",
|
||||
"build:client": "cross-env NODE_ENV=production webpack --progress --mode production && shx cp dist/index.html dist/index_template.html",
|
||||
"build": "npm run build:client",
|
||||
"analyze": "cross-env ANALYZE=true NODE_ENV=production webpack --progress --mode production",
|
||||
"lint": "eslint src/**/*.{js,jsx}",
|
||||
"prerender": "node prerender.cjs",
|
||||
"prerender:prod": "cross-env NODE_ENV=production node prerender.cjs",
|
||||
"build:prerender": "npm run build:client && npm run prerender:prod"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^7.1.1",
|
||||
"@mui/material": "^7.1.1",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
"@stripe/stripe-js": "^7.3.1",
|
||||
"chart.js": "^4.5.0",
|
||||
"html-react-parser": "^5.2.5",
|
||||
"react": "^19.1.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"sharp": "^0.34.2",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.27.4",
|
||||
"@babel/eslint-parser": "^7.27.5",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/register": "^7.26.0",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.0",
|
||||
"babel-loader": "^10.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"css-loader": "^7.1.2",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-webpack-plugin": "^5.0.2",
|
||||
"globals": "^16.2.0",
|
||||
"html-webpack-plugin": "^5.6.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"mini-css-extract-plugin": "^2.9.2",
|
||||
"react-refresh": "^0.17.0",
|
||||
"shx": "^0.4.0",
|
||||
"style-loader": "^4.0.0",
|
||||
"terser-webpack-plugin": "^5.3.14",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.2",
|
||||
"webpack-node-externals": "^3.0.0"
|
||||
}
|
||||
}
|
||||
732
prerender.cjs
Normal file
@@ -0,0 +1,732 @@
|
||||
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" };
|
||||
// Use Node.js URL constructor for React Router compatibility
|
||||
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");
|
||||
const os = require("os");
|
||||
const { Worker, isMainThread, parentPort, workerData } = require("worker_threads");
|
||||
|
||||
// Import split modules
|
||||
const config = require("./prerender/config.cjs");
|
||||
|
||||
// Import shop config - using require with Babel transpilation
|
||||
const shopConfig = require("./src/config.js").default;
|
||||
const { renderPage } = require("./prerender/renderer.cjs");
|
||||
const {
|
||||
collectAllCategories,
|
||||
writeCombinedCssFile,
|
||||
} = require("./prerender/utils.cjs");
|
||||
const {
|
||||
generateProductMetaTags,
|
||||
generateProductJsonLd,
|
||||
generateCategoryJsonLd,
|
||||
generateHomepageMetaTags,
|
||||
generateHomepageJsonLd,
|
||||
generateSitemapJsonLd,
|
||||
generateKonfiguratorMetaTags,
|
||||
generateXmlSitemap,
|
||||
generateRobotsTxt,
|
||||
generateProductsXml,
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
} = require("./prerender/seo.cjs");
|
||||
const {
|
||||
fetchCategoryProducts,
|
||||
fetchProductDetails,
|
||||
saveProductImages,
|
||||
saveCategoryImages,
|
||||
} = require("./prerender/data-fetching.cjs");
|
||||
|
||||
// Import components
|
||||
const PrerenderCategory = require("./src/PrerenderCategory.js").default;
|
||||
const PrerenderProduct = require("./src/PrerenderProduct.js").default;
|
||||
const PrerenderKonfigurator = require("./src/PrerenderKonfigurator.js").default;
|
||||
const PrerenderProfile = require("./src/PrerenderProfile.js").default;
|
||||
|
||||
// Import static page components
|
||||
const Datenschutz = require("./src/pages/Datenschutz.js").default;
|
||||
const Impressum = require("./src/pages/Impressum.js").default;
|
||||
const Batteriegesetzhinweise =
|
||||
require("./src/pages/Batteriegesetzhinweise.js").default;
|
||||
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
|
||||
const Sitemap = require("./src/pages/Sitemap.js").default;
|
||||
const AGB = require("./src/pages/AGB.js").default;
|
||||
|
||||
// Worker function for parallel product rendering
|
||||
const renderProductWorker = async (productSeoNames, workerId, progressCallback) => {
|
||||
const socketUrl = "http://127.0.0.1:9303";
|
||||
const workerSocket = io(socketUrl, {
|
||||
path: "/socket.io/",
|
||||
transports: ["polling", "websocket"],
|
||||
reconnection: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let processedCount = 0;
|
||||
let successCount = 0;
|
||||
const results = [];
|
||||
|
||||
const processNextProduct = async () => {
|
||||
if (processedCount >= productSeoNames.length) {
|
||||
workerSocket.disconnect();
|
||||
resolve({ successCount, results, workerId });
|
||||
return;
|
||||
}
|
||||
|
||||
const productSeoName = productSeoNames[processedCount];
|
||||
processedCount++;
|
||||
|
||||
try {
|
||||
const productDetails = await fetchProductDetails(workerSocket, productSeoName);
|
||||
|
||||
const actualSeoName = productDetails.product.seoName || productSeoName;
|
||||
const productComponent = React.createElement(PrerenderProduct, {
|
||||
productData: productDetails,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
const success = renderPage(
|
||||
productComponent,
|
||||
location,
|
||||
filename,
|
||||
description,
|
||||
combinedMetaTags,
|
||||
true,
|
||||
config,
|
||||
true // Suppress logs during parallel rendering to avoid interfering with progress bar
|
||||
);
|
||||
|
||||
if (success) {
|
||||
successCount++;
|
||||
}
|
||||
|
||||
const result = {
|
||||
productSeoName,
|
||||
productName: productDetails.product.name,
|
||||
success,
|
||||
workerId
|
||||
};
|
||||
|
||||
results.push(result);
|
||||
|
||||
// Call progress callback if provided
|
||||
if (progressCallback) {
|
||||
progressCallback(result);
|
||||
}
|
||||
|
||||
// Small delay to avoid overwhelming the server
|
||||
setTimeout(processNextProduct, 25);
|
||||
} catch (error) {
|
||||
const result = {
|
||||
productSeoName,
|
||||
productName: productSeoName,
|
||||
success: false,
|
||||
error: error.message,
|
||||
workerId
|
||||
};
|
||||
|
||||
results.push(result);
|
||||
|
||||
// Call progress callback if provided
|
||||
if (progressCallback) {
|
||||
progressCallback(result);
|
||||
}
|
||||
|
||||
setTimeout(processNextProduct, 25);
|
||||
}
|
||||
};
|
||||
|
||||
workerSocket.on("connect", () => {
|
||||
processNextProduct();
|
||||
});
|
||||
|
||||
workerSocket.on("connect_error", (err) => {
|
||||
console.error(`Worker ${workerId} socket connection error:`, err);
|
||||
resolve({ successCount: 0, results: [], workerId });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Function to render products in parallel
|
||||
const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProducts) => {
|
||||
// Shared progress tracking
|
||||
let completedProducts = 0;
|
||||
let totalSuccessCount = 0;
|
||||
let lastProductName = '';
|
||||
const progressResults = [];
|
||||
const workerCounts = new Array(maxWorkers).fill(0); // Track per-worker progress
|
||||
const workerSuccess = new Array(maxWorkers).fill(0); // Track per-worker success count
|
||||
|
||||
// Helper function to display progress bar with worker stats
|
||||
const updateProgressBar = (current, total, productName = '') => {
|
||||
const percentage = Math.round((current / total) * 100);
|
||||
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'}`);
|
||||
};
|
||||
|
||||
// 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]++;
|
||||
} else if (result.error) {
|
||||
// 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);
|
||||
});
|
||||
|
||||
workerPromises.push(promise);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log(`\n❌ ${errorResults.length} products failed to render:`);
|
||||
errorResults.forEach(result => {
|
||||
console.log(` - ${result.productSeoName}: ${result.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
return totalSuccessCount;
|
||||
} catch (error) {
|
||||
console.error('Error in parallel rendering:', error);
|
||||
return totalSuccessCount; // Return what we managed to complete
|
||||
}
|
||||
};
|
||||
|
||||
const renderApp = async (categoryData, socket) => {
|
||||
if (categoryData) {
|
||||
global.window.productCache = {
|
||||
categoryTree_209: { categoryTree: categoryData, timestamp: Date.now() },
|
||||
};
|
||||
// @note Make cache available to components during rendering
|
||||
global.productCache = global.window.productCache;
|
||||
} else {
|
||||
global.window.productCache = {};
|
||||
global.productCache = {};
|
||||
}
|
||||
|
||||
// Helper to call renderPage with config
|
||||
const render = (
|
||||
component,
|
||||
location,
|
||||
filename,
|
||||
description,
|
||||
metaTags = "",
|
||||
needsRouter = false
|
||||
) => {
|
||||
return renderPage(
|
||||
component,
|
||||
location,
|
||||
filename,
|
||||
description,
|
||||
metaTags,
|
||||
needsRouter,
|
||||
config
|
||||
);
|
||||
};
|
||||
|
||||
console.log("🏠 Rendering home page...");
|
||||
const PrerenderHome = require("./src/PrerenderHome.js").default;
|
||||
const homeComponent = React.createElement(PrerenderHome, null);
|
||||
const homeFilename = config.isProduction
|
||||
? "index.html"
|
||||
: "index.prerender.html";
|
||||
const homeMetaTags = generateHomepageMetaTags(shopConfig.baseUrl, shopConfig);
|
||||
const homeJsonLd = generateHomepageJsonLd(shopConfig.baseUrl, shopConfig);
|
||||
const combinedHomeMeta = homeMetaTags + "\n" + homeJsonLd;
|
||||
const homeSuccess = render(
|
||||
homeComponent,
|
||||
"/",
|
||||
homeFilename,
|
||||
"Home page",
|
||||
combinedHomeMeta,
|
||||
true
|
||||
);
|
||||
|
||||
if (!homeSuccess) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Render static pages
|
||||
console.log("\n📄 Rendering static pages...");
|
||||
|
||||
const staticPages = [
|
||||
{
|
||||
component: Datenschutz,
|
||||
path: "/datenschutz",
|
||||
filename: "datenschutz",
|
||||
description: "Datenschutz page",
|
||||
},
|
||||
{
|
||||
component: Impressum,
|
||||
path: "/impressum",
|
||||
filename: "impressum",
|
||||
description: "Impressum page",
|
||||
},
|
||||
{
|
||||
component: Batteriegesetzhinweise,
|
||||
path: "/batteriegesetzhinweise",
|
||||
filename: "batteriegesetzhinweise",
|
||||
description: "Batteriegesetzhinweise page",
|
||||
},
|
||||
{
|
||||
component: Widerrufsrecht,
|
||||
path: "/widerrufsrecht",
|
||||
filename: "widerrufsrecht",
|
||||
description: "Widerrufsrecht page",
|
||||
},
|
||||
{
|
||||
component: Sitemap,
|
||||
path: "/sitemap",
|
||||
filename: "sitemap",
|
||||
description: "Sitemap page",
|
||||
},
|
||||
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
|
||||
{
|
||||
component: PrerenderKonfigurator,
|
||||
path: "/Konfigurator",
|
||||
filename: "Konfigurator",
|
||||
description: "Growbox Konfigurator page",
|
||||
},
|
||||
{
|
||||
component: PrerenderProfile,
|
||||
path: "/profile",
|
||||
filename: "profile",
|
||||
description: "Profile page",
|
||||
},
|
||||
];
|
||||
|
||||
let staticPagesRendered = 0;
|
||||
for (const page of staticPages) {
|
||||
const pageComponent = React.createElement(page.component, null);
|
||||
let metaTags = "";
|
||||
|
||||
// Special handling for Sitemap page to include category data
|
||||
if (page.filename === "sitemap" && categoryData) {
|
||||
const allCategories = collectAllCategories(categoryData);
|
||||
metaTags = generateSitemapJsonLd(allCategories, shopConfig.baseUrl, shopConfig);
|
||||
}
|
||||
|
||||
// Special handling for Konfigurator page to include SEO tags
|
||||
if (page.filename === "Konfigurator") {
|
||||
const konfiguratorMetaTags = generateKonfiguratorMetaTags(shopConfig.baseUrl, shopConfig);
|
||||
metaTags = konfiguratorMetaTags;
|
||||
}
|
||||
|
||||
const success = render(
|
||||
pageComponent,
|
||||
page.path,
|
||||
page.filename,
|
||||
page.description,
|
||||
metaTags,
|
||||
true
|
||||
);
|
||||
if (success) {
|
||||
staticPagesRendered++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ Successfully rendered ${staticPagesRendered}/${staticPages.length} static pages!`
|
||||
);
|
||||
|
||||
// Collect all products for product page generation
|
||||
const allProducts = new Set();
|
||||
const allProductsData = []; // @note Store full product data for products.xml generation
|
||||
|
||||
// Generate category pages if we have category data
|
||||
if (categoryData && socket) {
|
||||
console.log("\n📂 Rendering category pages with product data...");
|
||||
const allCategories = collectAllCategories(categoryData);
|
||||
console.log(`Found ${allCategories.length} categories to render`);
|
||||
|
||||
// First, collect category images for all categories
|
||||
console.log("\n📂 Collecting category images...");
|
||||
await saveCategoryImages(socket, allCategories, config.outputDir);
|
||||
|
||||
let categoryPagesRendered = 0;
|
||||
let categoriesWithProducts = 0;
|
||||
|
||||
for (const category of allCategories) {
|
||||
// Skip categories without seoName
|
||||
if (!category.seoName) {
|
||||
console.log(
|
||||
`⚠️ Skipping category "${category.name}" (ID: ${category.id}) - no seoName`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`\n🔍 Fetching products for category "${category.name}" (ID: ${category.id})...`
|
||||
);
|
||||
|
||||
let productData = null;
|
||||
try {
|
||||
productData = await fetchCategoryProducts(socket, category.id);
|
||||
console.log(
|
||||
` ✅ Found ${
|
||||
productData.products ? productData.products.length : 0
|
||||
} products`
|
||||
);
|
||||
|
||||
if (productData.products && productData.products.length > 0) {
|
||||
categoriesWithProducts++;
|
||||
|
||||
// Collect products for individual page generation
|
||||
productData.products.forEach((product) => {
|
||||
if (product.seoName) {
|
||||
allProducts.add(product.seoName);
|
||||
// @note Store full product data for products.xml generation with category ID
|
||||
allProductsData.push({
|
||||
...product,
|
||||
seoName: product.seoName,
|
||||
categoryId: category.id // Add the category ID for Google Shopping category mapping
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch and save product images
|
||||
await saveProductImages(
|
||||
socket,
|
||||
productData.products,
|
||||
category.name,
|
||||
config.outputDir
|
||||
);
|
||||
|
||||
// Don't accumulate data in global cache - just use the data directly for this page
|
||||
// The global cache should only contain the static category tree
|
||||
}
|
||||
} catch (productError) {
|
||||
console.log(` ⚠️ No products found: ${productError.message}`);
|
||||
}
|
||||
|
||||
const categoryComponent = React.createElement(PrerenderCategory, {
|
||||
categoryId: category.id,
|
||||
categoryName: category.name,
|
||||
categorySeoName: category.seoName,
|
||||
productData: productData,
|
||||
});
|
||||
|
||||
const filename = `Kategorie/${category.seoName}`;
|
||||
const location = `/Kategorie/${category.seoName}`;
|
||||
const description = `Category "${category.name}" (ID: ${category.id})`;
|
||||
const categoryJsonLd = generateCategoryJsonLd(
|
||||
category,
|
||||
productData?.products || [],
|
||||
shopConfig.baseUrl,
|
||||
shopConfig
|
||||
);
|
||||
|
||||
const success = render(
|
||||
categoryComponent,
|
||||
location,
|
||||
filename,
|
||||
description,
|
||||
categoryJsonLd,
|
||||
true
|
||||
);
|
||||
if (success) {
|
||||
categoryPagesRendered++;
|
||||
}
|
||||
|
||||
// Small delay to avoid overwhelming the server
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Failed to render category ${category.id} (${category.name}):`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n🎉 Successfully rendered ${categoryPagesRendered} category pages!`
|
||||
);
|
||||
console.log(`📦 ${categoriesWithProducts} categories had product data`);
|
||||
|
||||
// Generate individual product pages
|
||||
if (allProducts.size > 0) {
|
||||
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
|
||||
|
||||
console.log(
|
||||
`\n📦 Rendering ${totalProducts} individual product pages using ${maxWorkers} parallel workers...`
|
||||
);
|
||||
|
||||
const productPagesRendered = await renderProductsInParallel(
|
||||
Array.from(allProducts),
|
||||
maxWorkers,
|
||||
totalProducts
|
||||
);
|
||||
|
||||
console.log(
|
||||
`🎉 Successfully rendered ${productPagesRendered}/${totalProducts} product pages!`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
"⚠️ No category data or socket available - skipping category page generation"
|
||||
);
|
||||
}
|
||||
|
||||
// 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...");
|
||||
const allCategories = categoryData ? collectAllCategories(categoryData) : [];
|
||||
const allProductsArray = Array.from(allProducts);
|
||||
const xmlSitemap = generateXmlSitemap(allCategories, allProductsArray, shopConfig.baseUrl);
|
||||
|
||||
const sitemapPath = path.resolve(__dirname, config.outputDir, "sitemap.xml");
|
||||
fs.writeFileSync(sitemapPath, xmlSitemap);
|
||||
|
||||
console.log(`✅ XML sitemap generated: ${sitemapPath}`);
|
||||
console.log(` - Homepage: 1 URL`);
|
||||
console.log(` - Static pages: 6 URLs`);
|
||||
console.log(` - Category pages: ${allCategories.length} URLs`);
|
||||
console.log(` - Product pages: ${allProductsArray.length} URLs`);
|
||||
console.log(
|
||||
` - Total URLs: ${1 + 6 + allCategories.length + allProductsArray.length}`
|
||||
);
|
||||
|
||||
// Generate robots.txt
|
||||
console.log("\n🤖 Generating robots.txt...");
|
||||
const robotsTxtContent = generateRobotsTxt(shopConfig.baseUrl);
|
||||
const robotsTxtPath = path.resolve(__dirname, config.outputDir, "robots.txt");
|
||||
fs.writeFileSync(robotsTxtPath, robotsTxtContent);
|
||||
|
||||
console.log(`✅ robots.txt generated: ${robotsTxtPath}`);
|
||||
console.log(` - Allows all crawlers`);
|
||||
console.log(` - References sitemap.xml`);
|
||||
console.log(` - Includes crawl-delay directive`);
|
||||
|
||||
// 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');
|
||||
console.log(` - File verification: ✅ Valid UTF-8 (${Math.round(verification.length / 1024)}KB)`);
|
||||
} catch (verifyError) {
|
||||
console.log(` - File verification: ⚠️ ${verifyError.message}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error generating products.xml: ${error.message}`);
|
||||
console.log("\n⚠️ Skipping products.xml generation due to errors");
|
||||
}
|
||||
} else {
|
||||
console.log("\n⚠️ No product data available - skipping products.xml generation");
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const categoryId = product.categoryId || 'uncategorized';
|
||||
if (!productsByCategory[categoryId]) {
|
||||
productsByCategory[categoryId] = [];
|
||||
}
|
||||
productsByCategory[categoryId].push(product);
|
||||
});
|
||||
|
||||
// Generate category-specific LLM files
|
||||
let categoryFilesGenerated = 0;
|
||||
let totalCategoryProducts = 0;
|
||||
|
||||
for (const category of allCategories) {
|
||||
if (category.seoName) {
|
||||
const categoryProducts = productsByCategory[category.id] || [];
|
||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
|
||||
const categoryLlmsTxt = generateCategoryLlmsTxt(category, categoryProducts, shopConfig.baseUrl, shopConfig);
|
||||
const categoryLlmsTxtPath = path.resolve(__dirname, config.outputDir, `llms-${categorySlug}.txt`);
|
||||
|
||||
fs.writeFileSync(categoryLlmsTxtPath, categoryLlmsTxt, { encoding: 'utf8' });
|
||||
|
||||
console.log(` ✅ llms-${categorySlug}.txt - ${categoryProducts.length} products (${Math.round(categoryLlmsTxt.length / 1024)}KB)`);
|
||||
|
||||
categoryFilesGenerated++;
|
||||
totalCategoryProducts += categoryProducts.length;
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCategoryDataAndRender = () => {
|
||||
const socketUrl = "http://127.0.0.1:9303";
|
||||
console.log(`Connecting to socket at ${socketUrl} to fetch categories...`);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
console.error(
|
||||
"Error: Prerender script timed out after 15 seconds. Check backend connectivity."
|
||||
);
|
||||
process.exit(1);
|
||||
}, 15000);
|
||||
|
||||
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"...');
|
||||
socket.emit("categoryList", { categoryId: 209 }, async (response) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (response && response.categoryTree) {
|
||||
console.log("Successfully fetched category data.");
|
||||
await renderApp(response.categoryTree, socket);
|
||||
} else {
|
||||
console.error("Error: Invalid category data received.", response);
|
||||
await renderApp(null, socket);
|
||||
}
|
||||
socket.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("connect_error", async (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error("Socket connection error:", err);
|
||||
await renderApp(null, null);
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
socket.on("error", async (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error("Socket error:", err);
|
||||
await renderApp(null, null);
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason) => {
|
||||
console.log(`Socket disconnected: ${reason}`);
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
};
|
||||
|
||||
fetchCategoryDataAndRender();
|
||||
71
prerender/config.cjs
Normal file
@@ -0,0 +1,71 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Determine if we're in production mode
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const outputDir = isProduction ? 'dist' : 'public';
|
||||
|
||||
console.log(`🔧 Prerender mode: ${isProduction ? 'PRODUCTION' : 'DEVELOPMENT'}`);
|
||||
console.log(`📁 Output directory: ${outputDir}`);
|
||||
|
||||
// Function to get webpack entrypoints for production
|
||||
const getWebpackEntrypoints = () => {
|
||||
if (!isProduction) return { js: [], css: [] };
|
||||
|
||||
const distPath = path.resolve(__dirname, '..', 'dist');
|
||||
const entrypoints = { js: [], css: [] };
|
||||
|
||||
try {
|
||||
// Look for the main HTML file to extract script and link tags
|
||||
const htmlPath = path.join(distPath, 'index.html');
|
||||
if (fs.existsSync(htmlPath)) {
|
||||
const htmlContent = fs.readFileSync(htmlPath, 'utf8');
|
||||
|
||||
// Extract script tags
|
||||
const scriptMatches = htmlContent.match(/<script[^>]*src="([^"]*)"[^>]*><\/script>/g) || [];
|
||||
scriptMatches.forEach(match => {
|
||||
const srcMatch = match.match(/src="([^"]*)"/);
|
||||
if (srcMatch) {
|
||||
entrypoints.js.push(srcMatch[1]);
|
||||
}
|
||||
});
|
||||
|
||||
// Extract CSS link tags
|
||||
const linkMatches = htmlContent.match(/<link[^>]*href="([^"]*\.css)"[^>]*>/g) || [];
|
||||
linkMatches.forEach(match => {
|
||||
const hrefMatch = match.match(/href="([^"]*)"/);
|
||||
if (hrefMatch) {
|
||||
entrypoints.css.push(hrefMatch[1]);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`📦 Found webpack entrypoints:`);
|
||||
console.log(` JS files: ${entrypoints.js.length} files`);
|
||||
console.log(` CSS files: ${entrypoints.css.length} files`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Could not read webpack entrypoints: ${error.message}`);
|
||||
}
|
||||
|
||||
return entrypoints;
|
||||
};
|
||||
|
||||
// Read global CSS styles and fix font paths for prerender
|
||||
let globalCss = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'index.css'), 'utf8');
|
||||
// Fix relative font paths for prerendered HTML (remove ../public to make them relative to public root)
|
||||
globalCss = globalCss.replace(/url\('\.\.\/public/g, "url('");
|
||||
|
||||
// Global CSS collection
|
||||
const globalCssCollection = new Set();
|
||||
|
||||
// Get webpack entrypoints
|
||||
const webpackEntrypoints = getWebpackEntrypoints();
|
||||
|
||||
module.exports = {
|
||||
isProduction,
|
||||
outputDir,
|
||||
getWebpackEntrypoints,
|
||||
globalCss,
|
||||
globalCssCollection,
|
||||
webpackEntrypoints
|
||||
};
|
||||
354
prerender/data-fetching.cjs
Normal file
@@ -0,0 +1,354 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const sharp = require("sharp");
|
||||
|
||||
const fetchCategoryTree = (socket, categoryId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(
|
||||
new Error(`Timeout fetching category tree for category ${categoryId}`)
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
socket.emit(
|
||||
"categoryList",
|
||||
{ categoryId: parseInt(categoryId) },
|
||||
(response) => {
|
||||
clearTimeout(timeout);
|
||||
if (response && response.categoryTree) {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`Invalid category tree response for category ${categoryId}: ${JSON.stringify(
|
||||
response
|
||||
)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchCategoryProducts = (socket, categoryId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`Timeout fetching products for category ${categoryId}`));
|
||||
}, 5000);
|
||||
|
||||
socket.emit(
|
||||
"getCategoryProducts",
|
||||
{ categoryId: parseInt(categoryId) },
|
||||
(response) => {
|
||||
clearTimeout(timeout);
|
||||
if (response && response.products !== undefined) {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`Invalid response for category ${categoryId}: ${JSON.stringify(
|
||||
response
|
||||
)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchProductDetails = (socket, productSeoName) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`Timeout fetching product details for product ${productSeoName}`
|
||||
)
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
socket.emit("getProductView", { seoName: productSeoName, nocount: true }, (response) => {
|
||||
clearTimeout(timeout);
|
||||
if (response && response.product) {
|
||||
response.product.seoName = productSeoName;
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`Invalid product response for product ${productSeoName}: ${JSON.stringify(
|
||||
response
|
||||
)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const fetchProductImage = (socket, bildId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`Timeout fetching image ${bildId}`));
|
||||
}, 10000);
|
||||
|
||||
socket.emit("getPic", { bildId, size: "medium" }, (res) => {
|
||||
clearTimeout(timeout);
|
||||
if (res.success && res.imageBuffer) {
|
||||
resolve(res.imageBuffer);
|
||||
} else {
|
||||
reject(
|
||||
new Error(`Failed to fetch image ${bildId}: ${JSON.stringify(res)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const fetchCategoryImage = (socket, categoryId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`Timeout fetching category image for category ${categoryId}`));
|
||||
}, 10000);
|
||||
|
||||
socket.emit("getCategoryPic", { categoryId }, (response) => {
|
||||
clearTimeout(timeout);
|
||||
if (response.success && response.image) {
|
||||
resolve(response.image);
|
||||
} else {
|
||||
reject(
|
||||
new Error(`Failed to fetch category image for ${categoryId}: ${JSON.stringify(response)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const saveProductImages = async (socket, products, categoryName, outputDir) => {
|
||||
if (!products || products.length === 0) return;
|
||||
|
||||
const assetsPath = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
outputDir,
|
||||
"assets",
|
||||
"images"
|
||||
);
|
||||
const overlayPath = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"public",
|
||||
"assets",
|
||||
"images",
|
||||
"sh.png"
|
||||
);
|
||||
|
||||
// Ensure assets/images directory exists
|
||||
if (!fs.existsSync(assetsPath)) {
|
||||
fs.mkdirSync(assetsPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if overlay file exists
|
||||
if (!fs.existsSync(overlayPath)) {
|
||||
console.log(
|
||||
` ⚠️ Overlay file not found at ${overlayPath} - images will be saved without overlay`
|
||||
);
|
||||
}
|
||||
|
||||
let imagesSaved = 0;
|
||||
let imagesSkipped = 0;
|
||||
|
||||
console.log(
|
||||
` 📷 Fetching images for ${products.length} products in "${categoryName}"...`
|
||||
);
|
||||
|
||||
for (const product of products) {
|
||||
if (product.pictureList && product.pictureList.trim()) {
|
||||
// Parse pictureList string to get image IDs
|
||||
const imageIds = product.pictureList
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id);
|
||||
|
||||
if (imageIds.length > 0) {
|
||||
// Process first image for each product
|
||||
const bildId = parseInt(imageIds[0]);
|
||||
const estimatedFilename = `prod${bildId}.jpg`; // We'll generate a filename based on the ID
|
||||
|
||||
const imagePath = path.join(assetsPath, estimatedFilename);
|
||||
|
||||
// Skip if image already exists
|
||||
if (fs.existsSync(imagePath)) {
|
||||
imagesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const imageBuffer = await fetchProductImage(socket, bildId);
|
||||
|
||||
// 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();
|
||||
|
||||
// Calculate center position for the resized overlay
|
||||
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", // Darkens the image, visible on all backgrounds
|
||||
opacity: 0.3,
|
||||
},
|
||||
])
|
||||
.jpeg() // Ensure output is JPEG
|
||||
.toBuffer();
|
||||
|
||||
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 image ${estimatedFilename} (ID: ${bildId}): ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imagesSaved > 0 || imagesSkipped > 0) {
|
||||
console.log(
|
||||
` 📷 Images: ${imagesSaved} saved, ${imagesSkipped} already exist`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCategoryImages = async (socket, categories, outputDir) => {
|
||||
if (!categories || categories.length === 0) {
|
||||
console.log(" ⚠️ No categories provided for image collection");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` 📂 Attempting to fetch images for ${categories.length} categories via socket calls...`);
|
||||
|
||||
// Debug: Log categories that will be processed
|
||||
console.log(" 🔍 Categories to process:");
|
||||
categories.forEach((cat, index) => {
|
||||
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.jpg`);
|
||||
});
|
||||
|
||||
const assetsPath = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
outputDir,
|
||||
"assets",
|
||||
"images"
|
||||
);
|
||||
|
||||
// Ensure assets/images directory exists
|
||||
if (!fs.existsSync(assetsPath)) {
|
||||
fs.mkdirSync(assetsPath, { recursive: true });
|
||||
}
|
||||
|
||||
let imagesSaved = 0;
|
||||
let imagesSkipped = 0;
|
||||
let categoriesProcessed = 0;
|
||||
|
||||
console.log(
|
||||
` 📂 Processing categories for image collection...`
|
||||
);
|
||||
|
||||
for (const category of categories) {
|
||||
categoriesProcessed++;
|
||||
|
||||
const estimatedFilename = `cat${category.id}.jpg`; // Use 'cat' prefix with category ID
|
||||
const imagePath = path.join(assetsPath, estimatedFilename);
|
||||
|
||||
// Skip if image already exists
|
||||
if (fs.existsSync(imagePath)) {
|
||||
imagesSkipped++;
|
||||
console.log(` ⏭️ Category image already exists: ${estimatedFilename} (${category.name})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(` 🔍 Fetching image for category "${category.name}" (ID: ${category.id})...`);
|
||||
const imageBuffer = await fetchCategoryImage(socket, category.id);
|
||||
|
||||
// Convert to Uint8Array if needed (similar to CategoryBox.js)
|
||||
const uint8Array = new Uint8Array(imageBuffer);
|
||||
|
||||
// Save category images without overlay processing
|
||||
fs.writeFileSync(imagePath, Buffer.from(uint8Array));
|
||||
console.log(
|
||||
` 💾 Saved category image: ${estimatedFilename} (${category.name})`
|
||||
);
|
||||
|
||||
imagesSaved++;
|
||||
|
||||
// Small delay to avoid overwhelming server
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
} catch (error) {
|
||||
console.log(
|
||||
` ⚠️ Failed to fetch category image for "${category.name}" (ID: ${category.id}): ${error.message}`
|
||||
);
|
||||
// Continue processing other categories even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
` 📂 Category image collection complete: ${imagesSaved} saved, ${imagesSkipped} already exist`
|
||||
);
|
||||
console.log(
|
||||
` 📊 Summary: ${categoriesProcessed}/${categories.length} categories processed`
|
||||
);
|
||||
|
||||
if (imagesSaved === 0 && imagesSkipped === 0) {
|
||||
console.log(" ⚠️ No category images were found via socket calls - categories may not have images available");
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
fetchCategoryTree,
|
||||
fetchCategoryProducts,
|
||||
fetchProductDetails,
|
||||
fetchProductImage,
|
||||
fetchCategoryImage,
|
||||
saveProductImages,
|
||||
saveCategoryImages,
|
||||
};
|
||||
254
prerender/renderer.cjs
Normal file
@@ -0,0 +1,254 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const React = require("react");
|
||||
const ReactDOMServer = require("react-dom/server");
|
||||
const { StaticRouter } = require("react-router");
|
||||
const { CacheProvider } = require("@emotion/react");
|
||||
const { ThemeProvider } = require("@mui/material/styles");
|
||||
const createEmotionCache = require("../createEmotionCache.js").default;
|
||||
const theme = require("../src/theme.js").default;
|
||||
const createEmotionServer = require("@emotion/server/create-instance").default;
|
||||
|
||||
const renderPage = (
|
||||
component,
|
||||
location,
|
||||
filename,
|
||||
description,
|
||||
metaTags = "",
|
||||
needsRouter = false,
|
||||
config,
|
||||
suppressLogs = false
|
||||
) => {
|
||||
const {
|
||||
isProduction,
|
||||
outputDir,
|
||||
globalCss,
|
||||
globalCssCollection,
|
||||
webpackEntrypoints,
|
||||
} = config;
|
||||
const { writeCombinedCssFile, optimizeCss } = require("./utils.cjs");
|
||||
|
||||
// @note Set prerender fallback flag in global environment for CategoryBox during SSR
|
||||
if (typeof global !== "undefined" && global.window) {
|
||||
global.window.__PRERENDER_FALLBACK__ = {
|
||||
path: location,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
// Create fresh Emotion cache for each page
|
||||
const cache = createEmotionCache();
|
||||
const { extractCriticalToChunks } = createEmotionServer(cache);
|
||||
|
||||
const wrappedComponent = needsRouter
|
||||
? React.createElement(StaticRouter, { location: location }, component)
|
||||
: component;
|
||||
|
||||
const pageElement = React.createElement(
|
||||
CacheProvider,
|
||||
{ value: cache },
|
||||
React.createElement(ThemeProvider, { theme: theme }, wrappedComponent)
|
||||
);
|
||||
|
||||
let renderedMarkup;
|
||||
|
||||
try {
|
||||
renderedMarkup = ReactDOMServer.renderToString(pageElement);
|
||||
const emotionChunks = extractCriticalToChunks(renderedMarkup);
|
||||
|
||||
// Collect CSS from this page
|
||||
if (emotionChunks.styles.length > 0) {
|
||||
const oldSize = globalCssCollection.size;
|
||||
|
||||
emotionChunks.styles.forEach((style) => {
|
||||
if (style.css) {
|
||||
globalCssCollection.add(style.css);
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use appropriate template path based on mode
|
||||
// In production, use a clean template file, not the already-rendered index.html
|
||||
const templatePath = isProduction
|
||||
? path.resolve(__dirname, "..", "dist", "index_template.html")
|
||||
: path.resolve(__dirname, "..", "public", "index.html");
|
||||
let template = fs.readFileSync(templatePath, "utf8");
|
||||
|
||||
// Build CSS and JS tags with optimized CSS loading
|
||||
let additionalTags = "";
|
||||
let inlinedCss = "";
|
||||
|
||||
if (isProduction) {
|
||||
// Check if scripts are already present in template to avoid duplication
|
||||
const existingScripts =
|
||||
template.match(/<script[^>]*src="([^"]*)"[^>]*><\/script>/g) || [];
|
||||
const existingScriptSrcs = existingScripts
|
||||
.map((script) => {
|
||||
const match = script.match(/src="([^"]*)"/);
|
||||
return match ? match[1] : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// OPTIMIZATION: Inline critical CSS instead of loading externally
|
||||
// Read and inline webpack CSS files to eliminate render-blocking requests
|
||||
webpackEntrypoints.css.forEach((cssFile) => {
|
||||
if (!template.includes(`href="${cssFile}"`)) {
|
||||
try {
|
||||
const cssPath = path.resolve(__dirname, "..", "dist", cssFile.replace(/^\//, ""));
|
||||
if (fs.existsSync(cssPath)) {
|
||||
const cssContent = fs.readFileSync(cssPath, "utf8");
|
||||
// Use advanced CSS optimization
|
||||
const optimizedCss = optimizeCss(cssContent);
|
||||
inlinedCss += optimizedCss;
|
||||
if (!suppressLogs) console.log(` ✅ Inlined CSS: ${cssFile} (${Math.round(optimizedCss.length / 1024)}KB)`);
|
||||
} else {
|
||||
// Fallback to external loading if file not found
|
||||
additionalTags += `<link rel="preload" href="${cssFile}" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
|
||||
additionalTags += `<noscript><link rel="stylesheet" href="${cssFile}"></noscript>`;
|
||||
if (!suppressLogs) console.log(` ⚠️ CSS file not found for inlining: ${cssPath}, using async loading`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to external loading if reading fails
|
||||
additionalTags += `<link rel="preload" href="${cssFile}" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
|
||||
additionalTags += `<noscript><link rel="stylesheet" href="${cssFile}"></noscript>`;
|
||||
if (!suppressLogs) console.log(` ⚠️ Error reading CSS file ${cssFile}: ${error.message}, using async loading`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
webpackEntrypoints.js.forEach((jsFile) => {
|
||||
if (!existingScriptSrcs.includes(jsFile)) {
|
||||
additionalTags += `<script src="${jsFile}"></script>`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// In development, try to inline prerender CSS as well
|
||||
try {
|
||||
const prerenderCssPath = path.resolve(__dirname, "..", outputDir, "prerender.css");
|
||||
if (fs.existsSync(prerenderCssPath)) {
|
||||
const prerenderCssContent = fs.readFileSync(prerenderCssPath, "utf8");
|
||||
const optimizedCss = optimizeCss(prerenderCssContent);
|
||||
inlinedCss += optimizedCss;
|
||||
if (!suppressLogs) console.log(` ✅ Inlined prerender CSS in development (${Math.round(optimizedCss.length / 1024)}KB)`);
|
||||
} else {
|
||||
// Fallback to external loading
|
||||
additionalTags += `<link rel="stylesheet" href="/prerender.css">`;
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to external loading
|
||||
additionalTags += `<link rel="stylesheet" href="/prerender.css">`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create script to save prerendered content to window object for fallback use
|
||||
const prerenderFallbackScript = `
|
||||
<script>
|
||||
// Save prerendered content to window object for SocketProvider fallback
|
||||
window.__PRERENDER_FALLBACK__ = {
|
||||
path: '${location}',
|
||||
content: ${JSON.stringify(renderedMarkup)},
|
||||
timestamp: ${Date.now()}
|
||||
};
|
||||
</script>
|
||||
`;
|
||||
|
||||
// @note Create script to populate window.productCache with ONLY the static category tree
|
||||
let productCacheScript = '';
|
||||
if (typeof global !== "undefined" && global.window && global.window.productCache) {
|
||||
// Only include the static categoryTree_209, not any dynamic data that gets added during rendering
|
||||
const staticCache = {};
|
||||
if (global.window.productCache.categoryTree_209) {
|
||||
staticCache.categoryTree_209 = global.window.productCache.categoryTree_209;
|
||||
}
|
||||
|
||||
const staticCacheData = JSON.stringify(staticCache);
|
||||
productCacheScript = `
|
||||
<script>
|
||||
// Populate window.productCache with static category tree only
|
||||
window.productCache = ${staticCacheData};
|
||||
</script>
|
||||
`;
|
||||
}
|
||||
|
||||
// Combine all CSS (global + inlined) into a single optimized style tag
|
||||
const combinedCss = globalCss + (inlinedCss ? '\n' + inlinedCss : '');
|
||||
const combinedCssTag = combinedCss ? `<style type="text/css">${combinedCss}</style>` : '';
|
||||
|
||||
// Add resource hints for better performance
|
||||
const resourceHints = `
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
`;
|
||||
|
||||
template = template.replace(
|
||||
"</head>",
|
||||
`${resourceHints}${combinedCssTag}${additionalTags}${metaTags}${prerenderFallbackScript}${productCacheScript}</head>`
|
||||
);
|
||||
|
||||
const rootDivRegex = /<div id="root"[\s\S]*?>[\s\S]*?<\/div>/;
|
||||
const replacementHtml = `<div id="root">${renderedMarkup}</div>`;
|
||||
|
||||
let newHtml;
|
||||
if (rootDivRegex.test(template)) {
|
||||
newHtml = template.replace(rootDivRegex, replacementHtml);
|
||||
} else {
|
||||
newHtml = template.replace("<body>", `<body>${replacementHtml}`);
|
||||
}
|
||||
|
||||
const outputPath = path.resolve(__dirname, "..", outputDir, filename);
|
||||
|
||||
// Ensure directory exists for nested paths
|
||||
const outputDirPath = path.dirname(outputPath);
|
||||
if (!fs.existsSync(outputDirPath)) {
|
||||
fs.mkdirSync(outputDirPath, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, newHtml);
|
||||
|
||||
if (!suppressLogs) {
|
||||
console.log(`✅ ${description} prerendered to ${outputPath}`);
|
||||
console.log(` - Markup length: ${renderedMarkup.length} characters`);
|
||||
console.log(` - CSS rules: ${Object.keys(cache.inserted).length}`);
|
||||
console.log(` - Total inlined CSS: ${Math.round(combinedCss.length / 1024)}KB`);
|
||||
console.log(` - Render-blocking CSS eliminated: ${inlinedCss ? 'YES' : 'NO'}`);
|
||||
console.log(` - Fallback content saved to window.__PRERENDER_FALLBACK__`);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
renderPage,
|
||||
};
|
||||
881
prerender/seo.cjs
Normal file
@@ -0,0 +1,881 @@
|
||||
const generateProductMetaTags = (product, baseUrl, config) => {
|
||||
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
|
||||
const imageUrl =
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.jpg`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
// Clean description for meta (remove HTML tags and limit length)
|
||||
const cleanDescription = product.description
|
||||
? product.description
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\n/g, " ")
|
||||
.substring(0, 160)
|
||||
: `${product.name} - Art.-Nr.: ${product.articleNumber}`;
|
||||
|
||||
return `
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="${cleanDescription}">
|
||||
<meta name="keywords" content="${product.name}, ${
|
||||
product.manufacturer || ""
|
||||
}, ${product.articleNumber}">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="${product.name}">
|
||||
<meta property="og:description" content="${cleanDescription}">
|
||||
<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}">
|
||||
<meta property="product:price:amount" content="${product.price}">
|
||||
<meta property="product:price:currency" content="${config.currency}">
|
||||
<meta property="product:availability" content="${
|
||||
product.available ? "in stock" : "out of stock"
|
||||
}">
|
||||
${product.gtin ? `<meta property="product:gtin" content="${product.gtin}">` : ''}
|
||||
${product.articleNumber ? `<meta property="product:retailer_item_id" content="${product.articleNumber}">` : ''}
|
||||
${product.manufacturer ? `<meta property="product:brand" content="${product.manufacturer}">` : ''}
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<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="${imageUrl}">
|
||||
|
||||
<!-- Additional Meta Tags -->
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="${productUrl}">
|
||||
`;
|
||||
};
|
||||
|
||||
const generateProductJsonLd = (product, baseUrl, config) => {
|
||||
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
|
||||
const imageUrl =
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.jpg`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
// Clean description for JSON-LD (remove HTML tags)
|
||||
const cleanDescription = product.description
|
||||
? product.description.replace(/<[^>]*>/g, "").replace(/\n/g, " ")
|
||||
: product.name;
|
||||
|
||||
// Calculate price valid date (current date + 3 months)
|
||||
const priceValidDate = new Date();
|
||||
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "Product",
|
||||
name: product.name,
|
||||
image: [imageUrl],
|
||||
description: cleanDescription,
|
||||
sku: product.articleNumber,
|
||||
...(product.gtin && { gtin: product.gtin }),
|
||||
brand: {
|
||||
"@type": "Brand",
|
||||
name: product.manufacturer || "Unknown",
|
||||
},
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
url: productUrl,
|
||||
priceCurrency: config.currency,
|
||||
price: product.price.toString(),
|
||||
priceValidUntil: priceValidDate.toISOString().split("T")[0],
|
||||
itemCondition: "https://schema.org/NewCondition",
|
||||
availability: product.available
|
||||
? "https://schema.org/InStock"
|
||||
: "https://schema.org/OutOfStock",
|
||||
seller: {
|
||||
"@type": "Organization",
|
||||
name: config.brandName,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return `<script type="application/ld+json">${JSON.stringify(
|
||||
jsonLd
|
||||
)}</script>`;
|
||||
};
|
||||
|
||||
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
|
||||
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "CollectionPage",
|
||||
name: category.name,
|
||||
url: categoryUrl,
|
||||
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`,
|
||||
breadcrumb: {
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Home",
|
||||
item: baseUrl,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: category.name,
|
||||
item: categoryUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Add product list if products are available
|
||||
if (products && products.length > 0) {
|
||||
jsonLd.mainEntity = {
|
||||
"@type": "ItemList",
|
||||
numberOfItems: products.length,
|
||||
itemListElement: products.slice(0, 20).map((product, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
item: {
|
||||
"@type": "Product",
|
||||
name: product.name,
|
||||
url: `${baseUrl}/Artikel/${product.seoName}`,
|
||||
image:
|
||||
product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList
|
||||
.split(",")[0]
|
||||
.trim()}.jpg`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: product.price.toString(),
|
||||
priceCurrency: config.currency,
|
||||
availability: product.available
|
||||
? "https://schema.org/InStock"
|
||||
: "https://schema.org/OutOfStock",
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return `<script type="application/ld+json">${JSON.stringify(
|
||||
jsonLd
|
||||
)}</script>`;
|
||||
};
|
||||
|
||||
const generateHomepageMetaTags = (baseUrl, config) => {
|
||||
const description = config.descriptions.long;
|
||||
const keywords = config.keywords;
|
||||
const imageUrl = `${baseUrl}${config.images.logo}`;
|
||||
|
||||
return `
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="${description}">
|
||||
<meta name="keywords" content="${keywords}">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<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="${baseUrl}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="${config.siteName}">
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${config.descriptions.short}">
|
||||
<meta name="twitter:description" content="${description}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
|
||||
<!-- Additional Meta Tags -->
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="${baseUrl}">
|
||||
`;
|
||||
};
|
||||
|
||||
const generateHomepageJsonLd = (baseUrl, config) => {
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "WebSite",
|
||||
name: config.brandName,
|
||||
url: baseUrl,
|
||||
description: config.descriptions.long,
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: config.brandName,
|
||||
url: baseUrl,
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: `${baseUrl}${config.images.logo}`,
|
||||
},
|
||||
},
|
||||
potentialAction: {
|
||||
"@type": "SearchAction",
|
||||
target: {
|
||||
"@type": "EntryPoint",
|
||||
urlTemplate: `${baseUrl}/search?q={search_term_string}`,
|
||||
},
|
||||
"query-input": "required name=search_term_string",
|
||||
},
|
||||
mainEntity: {
|
||||
"@type": "WebPage",
|
||||
name: "Sitemap",
|
||||
url: `${baseUrl}/sitemap`,
|
||||
description: "Vollständige Sitemap mit allen Kategorien und Seiten",
|
||||
},
|
||||
sameAs: [
|
||||
// Add your social media URLs here if available
|
||||
],
|
||||
};
|
||||
|
||||
return `<script type="application/ld+json">${JSON.stringify(
|
||||
jsonLd
|
||||
)}</script>`;
|
||||
};
|
||||
|
||||
const generateSitemapJsonLd = (allCategories = [], baseUrl, config) => {
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "WebPage",
|
||||
name: "Sitemap",
|
||||
url: `${baseUrl}/sitemap`,
|
||||
description: `Sitemap - Übersicht aller Kategorien und Seiten auf ${config.siteName}`,
|
||||
breadcrumb: {
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Home",
|
||||
item: baseUrl,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Sitemap",
|
||||
item: `${baseUrl}/sitemap`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Add all categories as site navigation elements
|
||||
if (allCategories && allCategories.length > 0) {
|
||||
jsonLd.mainEntity = {
|
||||
"@type": "SiteNavigationElement",
|
||||
name: "Kategorien",
|
||||
hasPart: allCategories.map((category) => ({
|
||||
"@type": "SiteNavigationElement",
|
||||
name: category.name,
|
||||
url: `${baseUrl}/Kategorie/${category.seoName}`,
|
||||
description: `${category.name} Kategorie`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return `<script type="application/ld+json">${JSON.stringify(
|
||||
jsonLd
|
||||
)}</script>`;
|
||||
};
|
||||
|
||||
const generateXmlSitemap = (allCategories = [], allProducts = [], baseUrl) => {
|
||||
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
|
||||
|
||||
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
`;
|
||||
|
||||
// Homepage
|
||||
sitemap += ` <url>
|
||||
<loc>${baseUrl}/</loc>
|
||||
<lastmod>${currentDate}</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
`;
|
||||
|
||||
// Static pages
|
||||
const staticPages = [
|
||||
{ path: "/datenschutz", changefreq: "monthly", priority: "0.3" },
|
||||
{ path: "/impressum", changefreq: "monthly", priority: "0.3" },
|
||||
{ path: "/batteriegesetzhinweise", changefreq: "monthly", priority: "0.3" },
|
||||
{ path: "/widerrufsrecht", changefreq: "monthly", priority: "0.3" },
|
||||
{ path: "/sitemap", changefreq: "weekly", priority: "0.5" },
|
||||
{ path: "/agb", changefreq: "monthly", priority: "0.3" },
|
||||
{ path: "/Konfigurator", changefreq: "weekly", priority: "0.8" },
|
||||
];
|
||||
|
||||
staticPages.forEach((page) => {
|
||||
sitemap += ` <url>
|
||||
<loc>${baseUrl}${page.path}</loc>
|
||||
<lastmod>${currentDate}</lastmod>
|
||||
<changefreq>${page.changefreq}</changefreq>
|
||||
<priority>${page.priority}</priority>
|
||||
</url>
|
||||
`;
|
||||
});
|
||||
|
||||
// Category pages
|
||||
allCategories.forEach((category) => {
|
||||
if (category.seoName) {
|
||||
sitemap += ` <url>
|
||||
<loc>${baseUrl}/Kategorie/${category.seoName}</loc>
|
||||
<lastmod>${currentDate}</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// Product pages
|
||||
allProducts.forEach((productSeoName) => {
|
||||
sitemap += ` <url>
|
||||
<loc>${baseUrl}/Artikel/${productSeoName}</loc>
|
||||
<lastmod>${currentDate}</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
`;
|
||||
});
|
||||
|
||||
sitemap += `</urlset>`;
|
||||
|
||||
return sitemap;
|
||||
};
|
||||
|
||||
const generateKonfiguratorMetaTags = (baseUrl, config) => {
|
||||
const description = "Unser interaktiver Growbox Konfigurator hilft dir dabei, das perfekte Indoor Growing Setup zusammenzustellen. Wähle aus verschiedenen Growbox-Größen, Beleuchtung, Belüftung und Extras. Bundle-Rabatte bis 36%!";
|
||||
const keywords = "Growbox Konfigurator, Indoor Growing, Growzelt, Beleuchtung, Belüftung, Growbox Setup, Indoor Garden";
|
||||
const imageUrl = `${baseUrl}${config.images.placeholder}`; // Placeholder image
|
||||
|
||||
return `
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="${description}">
|
||||
<meta name="keywords" content="${keywords}">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="Growbox Konfigurator - Stelle dein perfektes Indoor Grow Setup zusammen">
|
||||
<meta property="og:description" content="${description}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta property="og:url" content="${baseUrl}/Konfigurator">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="${config.siteName}">
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Growbox Konfigurator - Indoor Grow Setup">
|
||||
<meta name="twitter:description" content="${description}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
|
||||
<!-- Additional Meta Tags -->
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="${baseUrl}/Konfigurator">
|
||||
`;
|
||||
};
|
||||
|
||||
const generateRobotsTxt = (baseUrl) => {
|
||||
|
||||
const robotsTxt = `User-agent: *
|
||||
Allow: /
|
||||
Sitemap: ${baseUrl}/sitemap.xml
|
||||
Crawl-delay: 0
|
||||
`;
|
||||
|
||||
return robotsTxt;
|
||||
};
|
||||
|
||||
const generateProductsXml = (allProductsData = [], baseUrl, config) => {
|
||||
const currentDate = new Date().toISOString();
|
||||
|
||||
// Validate input
|
||||
if (!Array.isArray(allProductsData) || allProductsData.length === 0) {
|
||||
throw new Error("No valid product data provided");
|
||||
}
|
||||
|
||||
// Category mapping function
|
||||
const getGoogleProductCategory = (categoryId) => {
|
||||
const categoryMappings = {
|
||||
// Seeds & Plants
|
||||
689: "Home & Garden > Plants > Seeds",
|
||||
706: "Home & Garden > Plants", // Stecklinge (cuttings)
|
||||
376: "Home & Garden > Plants > Plant & Herb Growing Kits", // Grow-Sets
|
||||
|
||||
// Headshop & Accessories
|
||||
709: "Arts & Entertainment > Hobbies & Creative Arts", // Headshop
|
||||
711: "Arts & Entertainment > Hobbies & Creative Arts", // Bongs
|
||||
714: "Arts & Entertainment > Hobbies & Creative Arts", // Zubehör
|
||||
748: "Arts & Entertainment > Hobbies & Creative Arts", // Köpfe
|
||||
749: "Arts & Entertainment > Hobbies & Creative Arts", // Chillums / Diffusoren / Kupplungen
|
||||
896: "Electronics > Electronics Accessories", // Vaporizer
|
||||
710: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Grinder
|
||||
|
||||
// Measuring & Packaging
|
||||
186: "Business & Industrial", // Wiegen & Verpacken
|
||||
187: "Business & Industrial > Science & Laboratory > Lab Equipment", // Waagen
|
||||
346: "Home & Garden > Kitchen & Dining > Food Storage", // Vakuumbeutel
|
||||
355: "Home & Garden > Kitchen & Dining > Food Storage", // Boveda & Integra Boost
|
||||
407: "Home & Garden > Kitchen & Dining > Food Storage", // Grove Bags
|
||||
449: "Home & Garden > Kitchen & Dining > Food Storage", // Cliptütchen
|
||||
539: "Home & Garden > Kitchen & Dining > Food Storage", // Gläser & Dosen
|
||||
|
||||
// Lighting & Equipment
|
||||
694: "Home & Garden > Lighting", // Lampen
|
||||
261: "Home & Garden > Lighting", // Lampenzubehör
|
||||
|
||||
// Plants & Growing
|
||||
691: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger
|
||||
692: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger - Zubehör
|
||||
693: "Sporting Goods > Outdoor Recreation > Camping & Hiking > Tents", // Zelte
|
||||
|
||||
// Pots & Containers
|
||||
219: "Home & Garden > Decor > Planters & Pots", // Töpfe
|
||||
220: "Home & Garden > Decor > Planters & Pots", // Untersetzer
|
||||
301: "Home & Garden > Decor > Planters & Pots", // Stofftöpfe
|
||||
317: "Home & Garden > Decor > Planters & Pots", // Air-Pot
|
||||
364: "Home & Garden > Decor > Planters & Pots", // Kunststofftöpfe
|
||||
292: "Home & Garden > Decor > Planters & Pots", // Trays & Fluttische
|
||||
|
||||
// Ventilation & Climate
|
||||
703: "Home & Garden > Outdoor Power Tools", // Abluft-Sets
|
||||
247: "Home & Garden > Outdoor Power Tools", // Belüftung
|
||||
214: "Home & Garden > Outdoor Power Tools", // Umluft-Ventilatoren
|
||||
308: "Home & Garden > Outdoor Power Tools", // Ab- und Zuluft
|
||||
609: "Home & Garden > Outdoor Power Tools", // Schalldämpfer
|
||||
248: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Aktivkohlefilter
|
||||
392: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Zuluftfilter
|
||||
658: "Home & Garden > Climate Control > Dehumidifiers", // Luftbe- und entfeuchter
|
||||
310: "Home & Garden > Climate Control > Heating", // Heizmatten
|
||||
379: "Home & Garden > Household Supplies > Air Fresheners", // Geruchsneutralisation
|
||||
|
||||
// Irrigation & Watering
|
||||
221: "Home & Garden > Lawn & Garden > Watering Equipment", // Bewässerung
|
||||
250: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
|
||||
297: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpen
|
||||
354: "Home & Garden > Lawn & Garden > Watering Equipment", // Sprüher
|
||||
372: "Home & Garden > Lawn & Garden > Watering Equipment", // AutoPot
|
||||
389: "Home & Garden > Lawn & Garden > Watering Equipment", // Blumat
|
||||
405: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
|
||||
425: "Home & Garden > Lawn & Garden > Watering Equipment", // Wassertanks
|
||||
480: "Home & Garden > Lawn & Garden > Watering Equipment", // Tropfer
|
||||
519: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpsprüher
|
||||
|
||||
// Growing Media & Soils
|
||||
242: "Home & Garden > Lawn & Garden > Fertilizers", // Böden
|
||||
243: "Home & Garden > Lawn & Garden > Fertilizers", // Erde
|
||||
269: "Home & Garden > Lawn & Garden > Fertilizers", // Kokos
|
||||
580: "Home & Garden > Lawn & Garden > Fertilizers", // Perlite & Blähton
|
||||
|
||||
// Propagation & Starting
|
||||
286: "Home & Garden > Plants", // Anzucht
|
||||
298: "Home & Garden > Plants", // Steinwolltrays
|
||||
421: "Home & Garden > Plants", // Vermehrungszubehör
|
||||
489: "Home & Garden > Plants", // EazyPlug & Jiffy
|
||||
359: "Home & Garden > Outdoor Structures > Greenhouses", // Gewächshäuser
|
||||
|
||||
// Tools & Equipment
|
||||
373: "Home & Garden > Tools > Hand Tools", // GrowTool
|
||||
403: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Messbecher & mehr
|
||||
259: "Home & Garden > Tools > Hand Tools", // Pressen
|
||||
280: "Home & Garden > Tools > Hand Tools", // Erntescheeren
|
||||
258: "Home & Garden > Tools", // Ernte & Verarbeitung
|
||||
278: "Home & Garden > Tools", // Extraktion
|
||||
302: "Home & Garden > Tools", // Erntemaschinen
|
||||
|
||||
// Hardware & Plumbing
|
||||
222: "Hardware > Plumbing", // PE-Teile
|
||||
374: "Hardware > Plumbing > Plumbing Fittings", // Verbindungsteile
|
||||
|
||||
// Electronics & Control
|
||||
314: "Electronics > Electronics Accessories", // Steuergeräte
|
||||
408: "Electronics > Electronics Accessories", // GrowControl
|
||||
344: "Business & Industrial > Science & Laboratory > Lab Equipment", // Messgeräte
|
||||
555: "Business & Industrial > Science & Laboratory > Lab Equipment > Microscopes", // Mikroskope
|
||||
|
||||
// Camping & Outdoor
|
||||
226: "Sporting Goods > Outdoor Recreation > Camping & Hiking", // Zeltzubehör
|
||||
|
||||
// Plant Care & Protection
|
||||
239: "Home & Garden > Lawn & Garden > Pest Control", // Pflanzenschutz
|
||||
240: "Home & Garden > Plants", // Anbauzubehör
|
||||
|
||||
// Office & Media
|
||||
424: "Office Supplies > Labels", // Etiketten & Schilder
|
||||
387: "Media > Books", // Literatur
|
||||
|
||||
// General categories
|
||||
705: "Home & Garden", // Set-Konfigurator
|
||||
686: "Home & Garden", // Zubehör
|
||||
741: "Home & Garden", // Zubehör
|
||||
294: "Home & Garden", // Zubehör
|
||||
695: "Home & Garden", // Zubehör
|
||||
293: "Home & Garden", // Trockennetze
|
||||
4: "Home & Garden", // Sonstiges
|
||||
450: "Home & Garden", // Restposten
|
||||
};
|
||||
|
||||
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.short}</title>
|
||||
<link>${baseUrl}</link>
|
||||
<description>${config.descriptions.short}</description>
|
||||
<lastBuildDate>${currentDate}</lastBuildDate>
|
||||
<language>${config.language}</language>`;
|
||||
|
||||
// Helper function to clean text content of problematic characters
|
||||
const cleanTextContent = (text) => {
|
||||
if (!text) return "";
|
||||
|
||||
return text.toString()
|
||||
// Remove HTML tags
|
||||
.replace(/<[^>]*>/g, "")
|
||||
// Remove non-printable characters and control characters
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, '')
|
||||
// Remove BOM and other Unicode formatting characters
|
||||
.replace(/[\uFEFF\u200B-\u200D\u2060]/g, '')
|
||||
// Replace multiple whitespace with single space
|
||||
.replace(/\s+/g, ' ')
|
||||
// Remove leading/trailing whitespace
|
||||
.trim();
|
||||
};
|
||||
|
||||
// Helper function to properly escape XML content and remove invalid characters
|
||||
const escapeXml = (unsafe) => {
|
||||
if (!unsafe) return "";
|
||||
|
||||
// Convert to string and remove invalid XML characters
|
||||
const cleaned = unsafe.toString()
|
||||
// Remove control characters except tab (0x09), newline (0x0A), and carriage return (0x0D)
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
|
||||
// Remove invalid Unicode characters and surrogates
|
||||
.replace(/[\uD800-\uDFFF]/g, '')
|
||||
// Remove other problematic characters
|
||||
.replace(/[\uFFFE\uFFFF]/g, '')
|
||||
// Normalize whitespace
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
// Escape XML entities
|
||||
return cleaned
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
let processedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
// Category IDs to skip (seeds, plants, headshop items)
|
||||
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++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products from excluded categories
|
||||
const productCategoryId = product.categoryId || product.category_id || product.category || null;
|
||||
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
|
||||
skippedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products without GTIN
|
||||
if (!product.gtin || !product.gtin.toString().trim()) {
|
||||
skippedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products without pictures
|
||||
if (!product.pictureList || !product.pictureList.trim()) {
|
||||
skippedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean description for feed (remove HTML tags and limit length)
|
||||
const rawDescription = product.description
|
||||
? cleanTextContent(product.description).substring(0, 500)
|
||||
: `${product.name || 'Product'} - Art.-Nr.: ${product.articleNumber || 'N/A'}`;
|
||||
|
||||
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
|
||||
|
||||
// Clean product name
|
||||
const rawName = product.name || "Unnamed Product";
|
||||
const cleanName = escapeXml(cleanTextContent(rawName)) || "Unnamed Product";
|
||||
|
||||
// Validate essential fields
|
||||
if (!cleanName || cleanName.length < 2) {
|
||||
skippedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate product URL
|
||||
const productUrl = `${baseUrl}/Artikel/${encodeURIComponent(product.seoName)}`;
|
||||
|
||||
// Generate image URL
|
||||
const imageUrl = product.pictureList && product.pictureList.trim()
|
||||
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.jpg`
|
||||
: `${baseUrl}/assets/images/nopicture.jpg`;
|
||||
|
||||
// Generate brand (manufacturer)
|
||||
const rawBrand = product.manufacturer || config.brandName;
|
||||
const brand = escapeXml(cleanTextContent(rawBrand));
|
||||
|
||||
// Generate condition (always new for this type of shop)
|
||||
const condition = "new";
|
||||
|
||||
// Generate availability
|
||||
const availability = product.available ? "in stock" : "out of stock";
|
||||
|
||||
// Generate price (ensure it's a valid number)
|
||||
const price = product.price && !isNaN(product.price)
|
||||
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
|
||||
: `0.00 ${config.currency}`;
|
||||
|
||||
// Generate GTIN/EAN if available (using articleNumber as fallback)
|
||||
const rawGtin = product.gtin || "";
|
||||
const gtin = escapeXml(rawGtin.toString().trim());
|
||||
|
||||
// Generate product ID (using articleNumber or seoName)
|
||||
const rawProductId = product.articleNumber || product.seoName || `product_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
const productId = escapeXml(rawProductId.toString().trim()) || `fallback_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
|
||||
// Get Google product category based on product's category ID
|
||||
const categoryId = product.categoryId || product.category_id || product.category || null;
|
||||
const googleCategory = getGoogleProductCategory(categoryId);
|
||||
const escapedGoogleCategory = escapeXml(googleCategory);
|
||||
|
||||
// Build item XML with proper formatting
|
||||
productsXml += `
|
||||
<item>
|
||||
<g:id>${productId}</g:id>
|
||||
<g:title>${cleanName}</g:title>
|
||||
<g:description>${cleanDescription}</g:description>
|
||||
<g:link>${productUrl}</g:link>
|
||||
<g:image_link>${imageUrl}</g:image_link>
|
||||
<g:condition>${condition}</g:condition>
|
||||
<g:availability>${availability}</g:availability>
|
||||
<g:price>${price}</g:price>
|
||||
<g:shipping>
|
||||
<g:country>${config.country}</g:country>
|
||||
<g:service>${config.shipping.defaultService}</g:service>
|
||||
<g:price>${config.shipping.defaultCost}</g:price>
|
||||
</g:shipping>
|
||||
<g:brand>${brand}</g:brand>
|
||||
<g:google_product_category>${escapedGoogleCategory}</g:google_product_category>
|
||||
<g:product_type>Gartenbedarf</g:product_type>`;
|
||||
|
||||
// Add GTIN if available
|
||||
if (gtin && gtin.trim()) {
|
||||
productsXml += `
|
||||
<g:gtin>${gtin}</g:gtin>`;
|
||||
}
|
||||
|
||||
// Add weight if available
|
||||
if (product.weight && !isNaN(product.weight)) {
|
||||
productsXml += `
|
||||
<g:shipping_weight>${parseFloat(product.weight).toFixed(2)} g</g:shipping_weight>`;
|
||||
}
|
||||
|
||||
productsXml += `
|
||||
</item>`;
|
||||
|
||||
processedCount++;
|
||||
|
||||
} catch (itemError) {
|
||||
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
|
||||
skippedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
productsXml += `
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
|
||||
|
||||
return productsXml;
|
||||
};
|
||||
|
||||
const generateLlmsTxt = (allCategories = [], allProductsData = [], baseUrl, config) => {
|
||||
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
|
||||
|
||||
// Group products by category for statistics
|
||||
const productsByCategory = {};
|
||||
allProductsData.forEach((product) => {
|
||||
const categoryId = product.categoryId || 'uncategorized';
|
||||
if (!productsByCategory[categoryId]) {
|
||||
productsByCategory[categoryId] = [];
|
||||
}
|
||||
productsByCategory[categoryId].push(product);
|
||||
});
|
||||
|
||||
// Find category names for organization
|
||||
const categoryMap = {};
|
||||
allCategories.forEach((cat) => {
|
||||
categoryMap[cat.id] = cat.name;
|
||||
});
|
||||
|
||||
let llmsTxt = `# ${config.siteName} - Site Map for LLMs
|
||||
|
||||
Generated: ${currentDate}
|
||||
Base URL: ${baseUrl}
|
||||
|
||||
## About ${config.brandName}
|
||||
SeedHeads is a German online shop specializing in high-quality seeds, plants, and gardening supplies. We offer a comprehensive range of products for indoor and outdoor growing, including seeds, cuttings, grow equipment, lighting, ventilation, fertilizers, and accessories.
|
||||
|
||||
## Site Structure
|
||||
|
||||
### Static Pages
|
||||
- **Home** - ${baseUrl}/
|
||||
- **Datenschutz (Privacy Policy)** - ${baseUrl}/datenschutz
|
||||
- **Impressum (Legal Notice)** - ${baseUrl}/impressum
|
||||
- **AGB (Terms & Conditions)** - ${baseUrl}/agb
|
||||
- **Widerrufsrecht (Right of Withdrawal)** - ${baseUrl}/widerrufsrecht
|
||||
- **Batteriegesetzhinweise (Battery Law Notice)** - ${baseUrl}/batteriegesetzhinweise
|
||||
- **Sitemap** - ${baseUrl}/sitemap
|
||||
- **Growbox Konfigurator** - ${baseUrl}/Konfigurator - Interactive tool to configure grow box setups with bundle discounts
|
||||
- **Profile** - ${baseUrl}/profile - User account and order management
|
||||
|
||||
### Site Features
|
||||
- **Language**: German (${config.language})
|
||||
- **Currency**: ${config.currency} (Euro)
|
||||
- **Shipping**: ${config.country}
|
||||
- **Payment Methods**: Credit Cards, PayPal, Bank Transfer, Cash on Delivery, Cash on Pickup
|
||||
|
||||
### Product Categories (${allCategories.length} categories)
|
||||
|
||||
`;
|
||||
|
||||
// Add categories with links to their detailed LLM files
|
||||
allCategories.forEach((category) => {
|
||||
if (category.seoName) {
|
||||
const productCount = productsByCategory[category.id]?.length || 0;
|
||||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
|
||||
llmsTxt += `#### ${category.name} (${productCount} products)
|
||||
- **Product Catalog**: ${baseUrl}/llms-${categorySlug}.txt
|
||||
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
llmsTxt += `
|
||||
---
|
||||
|
||||
*This sitemap is automatically generated during the site build process and includes all publicly accessible content. For technical inquiries, please refer to our contact information in the Impressum.*
|
||||
`;
|
||||
|
||||
return llmsTxt;
|
||||
};
|
||||
|
||||
const generateCategoryLlmsTxt = (category, categoryProducts = [], baseUrl, config) => {
|
||||
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
|
||||
|
||||
let categoryLlmsTxt = `# ${category.name} - Product Catalog
|
||||
|
||||
Generated: ${currentDate}
|
||||
Base URL: ${baseUrl}
|
||||
Category: ${category.name} (ID: ${category.id})
|
||||
Category URL: ${baseUrl}/Kategorie/${category.seoName}
|
||||
|
||||
## Category Overview
|
||||
This file contains all products in the "${category.name}" category from ${config.siteName}.
|
||||
|
||||
**Statistics:**
|
||||
- **Total Products**: ${categoryProducts.length}
|
||||
- **Category ID**: ${category.id}
|
||||
- **Category URL**: ${baseUrl}/Kategorie/${category.seoName}
|
||||
- **Back to Main Sitemap**: ${baseUrl}/llms.txt
|
||||
|
||||
`;
|
||||
|
||||
if (categoryProducts.length > 0) {
|
||||
categoryProducts.forEach((product, index) => {
|
||||
if (product.seoName) {
|
||||
// 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)
|
||||
: "";
|
||||
|
||||
categoryLlmsTxt += `## ${index + 1}. ${product.name}
|
||||
|
||||
- **Product URL**: ${baseUrl}/Artikel/${product.seoName}
|
||||
- **Article Number**: ${product.articleNumber || 'N/A'}
|
||||
- **Price**: €${product.price || '0.00'}
|
||||
- **Brand**: ${product.manufacturer || config.brandName}
|
||||
- **Availability**: ${product.available ? 'In Stock' : 'Out of Stock'}`;
|
||||
|
||||
if (product.gtin) {
|
||||
categoryLlmsTxt += `
|
||||
- **GTIN**: ${product.gtin}`;
|
||||
}
|
||||
|
||||
if (product.weight && !isNaN(product.weight)) {
|
||||
categoryLlmsTxt += `
|
||||
- **Weight**: ${product.weight}g`;
|
||||
}
|
||||
|
||||
if (cleanDescription) {
|
||||
categoryLlmsTxt += `
|
||||
|
||||
**Description:**
|
||||
${cleanDescription}${product.description && product.description.length > 300 ? '...' : ''}`;
|
||||
}
|
||||
|
||||
categoryLlmsTxt += `
|
||||
|
||||
---
|
||||
|
||||
`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
categoryLlmsTxt += `## No Products Available
|
||||
|
||||
This category currently contains no products.
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
categoryLlmsTxt += `---
|
||||
|
||||
*This category product list is automatically generated during the site build process. Product availability and pricing are updated in real-time on the main website.*
|
||||
`;
|
||||
|
||||
return categoryLlmsTxt;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
generateProductMetaTags,
|
||||
generateProductJsonLd,
|
||||
generateCategoryJsonLd,
|
||||
generateHomepageMetaTags,
|
||||
generateHomepageJsonLd,
|
||||
generateSitemapJsonLd,
|
||||
generateKonfiguratorMetaTags,
|
||||
generateXmlSitemap,
|
||||
generateRobotsTxt,
|
||||
generateProductsXml,
|
||||
generateLlmsTxt,
|
||||
generateCategoryLlmsTxt,
|
||||
};
|
||||
130
prerender/utils.cjs
Normal file
@@ -0,0 +1,130 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Helper function to collect all categories from the tree
|
||||
const collectAllCategories = (categoryNode, categories = []) => {
|
||||
if (!categoryNode) return categories;
|
||||
|
||||
// Add current category (skip root category 209)
|
||||
if (categoryNode.id !== 209) {
|
||||
categories.push({
|
||||
id: categoryNode.id,
|
||||
name: categoryNode.name,
|
||||
seoName: categoryNode.seoName,
|
||||
parentId: categoryNode.parentId
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively add children
|
||||
if (categoryNode.children) {
|
||||
for (const child of categoryNode.children) {
|
||||
collectAllCategories(child, categories);
|
||||
}
|
||||
}
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
// Advanced CSS minification and optimization
|
||||
const optimizeCss = (cssContent) => {
|
||||
if (!cssContent || typeof cssContent !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
let optimized = cssContent
|
||||
// Remove comments (/* ... */)
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
// Remove unnecessary whitespace but preserve structure
|
||||
.replace(/\s*{\s*/g, '{')
|
||||
.replace(/;\s*}/g, '}')
|
||||
.replace(/}\s*/g, '}')
|
||||
.replace(/,\s*/g, ',')
|
||||
.replace(/:\s*/g, ':')
|
||||
.replace(/;\s*/g, ';')
|
||||
// Remove empty rules
|
||||
.replace(/[^}]*\{\s*\}/g, '')
|
||||
// Normalize multiple spaces/tabs/newlines
|
||||
.replace(/\s+/g, ' ')
|
||||
// Remove leading/trailing whitespace
|
||||
.trim();
|
||||
|
||||
// Remove redundant semicolons before closing braces
|
||||
optimized = optimized.replace(/;+}/g, '}');
|
||||
|
||||
// Remove empty media queries
|
||||
optimized = optimized.replace(/@media[^{]*\{\s*\}/g, '');
|
||||
|
||||
return optimized;
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ CSS optimization failed: ${error.message}`);
|
||||
return cssContent; // Return original if optimization fails
|
||||
}
|
||||
};
|
||||
|
||||
// Extract critical CSS selectors (basic implementation)
|
||||
const extractCriticalCss = (cssContent, criticalSelectors = []) => {
|
||||
if (!cssContent || !criticalSelectors.length) {
|
||||
return { critical: '', nonCritical: cssContent };
|
||||
}
|
||||
|
||||
try {
|
||||
const rules = cssContent.match(/[^{}]+\{[^{}]*\}/g) || [];
|
||||
let critical = '';
|
||||
let nonCritical = '';
|
||||
|
||||
rules.forEach(rule => {
|
||||
const selector = rule.split('{')[0].trim();
|
||||
const isCritical = criticalSelectors.some(criticalSel => {
|
||||
return selector.includes(criticalSel) ||
|
||||
selector.includes('body') ||
|
||||
selector.includes('html') ||
|
||||
selector.includes(':root') ||
|
||||
selector.includes('@font-face') ||
|
||||
selector.includes('@import');
|
||||
});
|
||||
|
||||
if (isCritical) {
|
||||
critical += rule;
|
||||
} else {
|
||||
nonCritical += rule;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
critical: optimizeCss(critical),
|
||||
nonCritical: optimizeCss(nonCritical)
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Critical CSS extraction failed: ${error.message}`);
|
||||
return { critical: cssContent, nonCritical: '' };
|
||||
}
|
||||
};
|
||||
|
||||
const writeCombinedCssFile = (globalCssCollection, outputDir) => {
|
||||
const combinedCss = Array.from(globalCssCollection).join('\n');
|
||||
|
||||
// Optimize the combined CSS
|
||||
const optimizedCss = optimizeCss(combinedCss);
|
||||
|
||||
const cssFilePath = path.resolve(__dirname, '..', outputDir, 'prerender.css');
|
||||
fs.writeFileSync(cssFilePath, optimizedCss);
|
||||
|
||||
const originalSize = combinedCss.length;
|
||||
const optimizedSize = optimizedCss.length;
|
||||
const savings = originalSize - optimizedSize;
|
||||
const savingsPercent = originalSize > 0 ? Math.round((savings / originalSize) * 100) : 0;
|
||||
|
||||
console.log(`✅ Combined CSS file written to ${cssFilePath}`);
|
||||
console.log(` - Total CSS rules: ${globalCssCollection.size}`);
|
||||
console.log(` - Original size: ${Math.round(originalSize / 1024)}KB`);
|
||||
console.log(` - Optimized size: ${Math.round(optimizedSize / 1024)}KB`);
|
||||
console.log(` - Space saved: ${Math.round(savings / 1024)}KB (${savingsPercent}%)`);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
collectAllCategories,
|
||||
writeCombinedCssFile,
|
||||
optimizeCss,
|
||||
extractCriticalCss
|
||||
};
|
||||
BIN
public/assets/fonts/SwashingtonCP.ttf
Normal file
BIN
public/assets/fonts/SwashingtonCP_o.ttf
Normal file
BIN
public/assets/fonts/roboto/Roboto-Bold.ttf
Normal file
BIN
public/assets/fonts/roboto/Roboto-Light.ttf
Normal file
BIN
public/assets/fonts/roboto/Roboto-Medium.ttf
Normal file
BIN
public/assets/fonts/roboto/Roboto-Regular.ttf
Normal file
BIN
public/assets/images/1.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/images/2.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
public/assets/images/5.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/images/8.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/assets/images/cards.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
public/assets/images/cash.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/assets/images/cutlings.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/assets/images/gg.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/assets/images/giropay.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/assets/images/maestro.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/assets/images/maps.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/assets/images/mastercard.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/assets/images/nopicture.jpg
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/assets/images/reviews.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
public/assets/images/seeds.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
public/assets/images/sh.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/assets/images/sh_.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
public/assets/images/visa_electron.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 66 KiB |
39
public/index.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="SeedHeads - Ihr Online-Shop für hochwertige Samen, Pflanzen und Gartenbedarf. Entdecken Sie unser großes Sortiment an Saatgut, Pflanzen und Gartenzubehör für Ihren grünen Daumen.">
|
||||
<base href="/">
|
||||
<title>SeedHeads.de</title>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
</head>
|
||||
<body style="overflow-y: scroll;">
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// Get git commit from meta tag
|
||||
window.gitCommit = document.querySelector('meta[name="git-commit"]')?.content || 'unknown';
|
||||
console.log('Git commit:', window.gitCommit);
|
||||
function checkForUpdates() {
|
||||
fetch('/?nocache=' + new Date().getTime())
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const metaCommit = doc.querySelector('meta[name="git-commit"]')?.content;
|
||||
|
||||
if (metaCommit && metaCommit !== window.gitCommit) {
|
||||
console.log('New version available (from HTML meta), refreshing...');
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Update check via HTML failed:', error));
|
||||
}
|
||||
|
||||
// Check for updates every 5 minutes
|
||||
//setInterval(checkForUpdates, 30 * 1000);
|
||||
// Also check once shortly after page load
|
||||
setTimeout(checkForUpdates, 1000 * 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
325
src/App.js
Normal file
@@ -0,0 +1,325 @@
|
||||
import React, { useState, useEffect, useRef, useContext, lazy, Suspense } from "react";
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Fab from "@mui/material/Fab";
|
||||
import SmartToyIcon from "@mui/icons-material/SmartToy";
|
||||
import PaletteIcon from "@mui/icons-material/Palette";
|
||||
|
||||
import SocketProvider from "./providers/SocketProvider.js";
|
||||
import SocketContext from "./contexts/SocketContext.js";
|
||||
import config from "./config.js";
|
||||
import ScrollToTop from "./components/ScrollToTop.js";
|
||||
//import TelemetryService from './services/telemetryService.js';
|
||||
|
||||
import Header from "./components/Header.js";
|
||||
import Footer from "./components/Footer.js";
|
||||
import Home from "./pages/Home.js";
|
||||
|
||||
// Lazy load all route components to reduce initial bundle size
|
||||
const Content = lazy(() => import(/* webpackChunkName: "content" */ "./components/Content.js"));
|
||||
const ProductDetailWithSocket = lazy(() => import(/* webpackChunkName: "product-detail" */ "./components/ProductDetailWithSocket.js"));
|
||||
const ProfilePageWithSocket = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
|
||||
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
|
||||
|
||||
// Lazy load admin pages - only loaded when admin users access them
|
||||
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
|
||||
const UsersPage = lazy(() => import(/* webpackChunkName: "admin-users" */ "./pages/UsersPage.js"));
|
||||
const ServerLogsPage = lazy(() => import(/* webpackChunkName: "admin-logs" */ "./pages/ServerLogsPage.js"));
|
||||
|
||||
// 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 Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
|
||||
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
|
||||
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
|
||||
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
|
||||
|
||||
// Lazy load special features
|
||||
const GrowTentKonfigurator = lazy(() => import(/* webpackChunkName: "konfigurator" */ "./pages/GrowTentKonfigurator.js"));
|
||||
const ChatAssistant = lazy(() => import(/* webpackChunkName: "chat" */ "./components/ChatAssistant.js"));
|
||||
|
||||
// Import theme from separate file to reduce main bundle size
|
||||
import defaultTheme from "./theme.js";
|
||||
// Lazy load theme customizer for development only
|
||||
const ThemeCustomizerDialog = lazy(() => import(/* webpackChunkName: "theme-customizer" */ "./components/ThemeCustomizerDialog.js"));
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
|
||||
const deleteMessages = () => {
|
||||
console.log("Deleting messages");
|
||||
window.chatMessages = [];
|
||||
};
|
||||
|
||||
// Component to initialize telemetry service with socket
|
||||
const TelemetryInitializer = ({ socket }) => {
|
||||
const telemetryServiceRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket && !telemetryServiceRef.current) {
|
||||
//telemetryServiceRef.current = new TelemetryService(socket);
|
||||
//telemetryServiceRef.current.init();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (telemetryServiceRef.current) {
|
||||
telemetryServiceRef.current.destroy();
|
||||
telemetryServiceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
};
|
||||
|
||||
const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
// 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);
|
||||
|
||||
// Get current location
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (location.hash && location.hash.startsWith("#ORD-")) {
|
||||
if (location.pathname !== "/profile") {
|
||||
navigate(`/profile${location.hash}`, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [location, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLogin = () => {
|
||||
setAuthVersion((v) => v + 1);
|
||||
};
|
||||
window.addEventListener("userLoggedIn", handleLogin);
|
||||
return () => {
|
||||
window.removeEventListener("userLoggedIn", handleLogin);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Extract categoryId from pathname if on category route
|
||||
const getCategoryId = () => {
|
||||
const match = location.pathname.match(/^\/Kategorie\/(.+)$/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const categoryId = getCategoryId();
|
||||
|
||||
// Handler to toggle chat visibility
|
||||
const handleChatToggle = () => {
|
||||
if (isChatOpen)
|
||||
window.messageDeletionTimeout = setTimeout(deleteMessages, 1000 * 60);
|
||||
if (!isChatOpen && window.messageDeletionTimeout)
|
||||
clearTimeout(window.messageDeletionTimeout);
|
||||
setChatOpen(!isChatOpen);
|
||||
};
|
||||
|
||||
// Handler to close the chat
|
||||
const handleChatClose = () => {
|
||||
window.messageDeletionTimeout = setTimeout(deleteMessages, 1000 * 60);
|
||||
setChatOpen(false);
|
||||
};
|
||||
|
||||
// @note Theme customizer handlers for development mode
|
||||
const handleThemeCustomizerToggle = () => {
|
||||
setThemeCustomizerOpen(!isThemeCustomizerOpen);
|
||||
};
|
||||
|
||||
// Check if we're in development mode
|
||||
const isDevelopment = process.env.NODE_ENV === "development";
|
||||
|
||||
const socket = useContext(SocketContext);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: "100vh",
|
||||
mb: 0,
|
||||
pb: 0,
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<TelemetryInitializer socket={socket} />
|
||||
<Header active categoryId={categoryId} key={authVersion} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Suspense fallback={
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Box>
|
||||
}>
|
||||
<Routes>
|
||||
{/* Home page with text only */}
|
||||
<Route path="/" element={<Home />} />
|
||||
|
||||
{/* Category page - Render Content in parallel */}
|
||||
<Route
|
||||
path="/Kategorie/:categoryId"
|
||||
element={<Content socket={socket} />}
|
||||
/>
|
||||
{/* Single product page */}
|
||||
<Route
|
||||
path="/Artikel/:seoName"
|
||||
element={<ProductDetailWithSocket />}
|
||||
/>
|
||||
|
||||
{/* Search page - Render Content in parallel */}
|
||||
<Route path="/search" element={<Content socket={socket} />} />
|
||||
|
||||
{/* Profile page */}
|
||||
<Route path="/profile" element={<ProfilePageWithSocket />} />
|
||||
|
||||
{/* Reset password page */}
|
||||
<Route
|
||||
path="/resetPassword"
|
||||
element={<ResetPassword socket={socket} />}
|
||||
/>
|
||||
|
||||
{/* Admin page */}
|
||||
<Route path="/admin" element={<AdminPage socket={socket} />} />
|
||||
|
||||
{/* Admin Users page */}
|
||||
<Route path="/admin/users" element={<UsersPage socket={socket} />} />
|
||||
|
||||
{/* Admin Server Logs page */}
|
||||
<Route path="/admin/logs" element={<ServerLogsPage socket={socket} />} />
|
||||
|
||||
{/* Legal pages */}
|
||||
<Route path="/datenschutz" element={<Datenschutz />} />
|
||||
<Route path="/agb" element={<AGB />} />
|
||||
<Route path="/sitemap" element={<Sitemap />} />
|
||||
<Route path="/impressum" element={<Impressum />} />
|
||||
<Route
|
||||
path="/batteriegesetzhinweise"
|
||||
element={<Batteriegesetzhinweise />}
|
||||
/>
|
||||
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
|
||||
|
||||
{/* Grow Tent Configurator */}
|
||||
<Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
|
||||
|
||||
{/* Fallback for undefined routes */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Box>
|
||||
{/* Conditionally render the Chat Assistant */}
|
||||
{isChatOpen && (
|
||||
<Suspense fallback={<CircularProgress size={20} />}>
|
||||
<ChatAssistant
|
||||
open={isChatOpen}
|
||||
onClose={handleChatClose}
|
||||
socket={socket}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Chat AI Assistant FAB */}
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label="chat"
|
||||
size="small"
|
||||
sx={{
|
||||
position: "fixed",
|
||||
bottom: 31,
|
||||
right: 15,
|
||||
}}
|
||||
onClick={handleChatToggle} // Attach toggle handler
|
||||
>
|
||||
<SmartToyIcon sx={{ fontSize: "1.2rem" }} />
|
||||
</Fab>
|
||||
|
||||
{/* Development-only Theme Customizer FAB */}
|
||||
{isDevelopment && (
|
||||
<Fab
|
||||
color="secondary"
|
||||
aria-label="theme customizer"
|
||||
size="small"
|
||||
sx={{
|
||||
position: "fixed",
|
||||
bottom: 31,
|
||||
right: 75,
|
||||
}}
|
||||
onClick={handleThemeCustomizerToggle}
|
||||
>
|
||||
<PaletteIcon sx={{ fontSize: "1.2rem" }} />
|
||||
</Fab>
|
||||
)}
|
||||
|
||||
{/* Development-only Theme Customizer Dialog */}
|
||||
{isDevelopment && isThemeCustomizerOpen && (
|
||||
<Suspense fallback={<CircularProgress size={20} />}>
|
||||
<ThemeCustomizerDialog
|
||||
open={isThemeCustomizerOpen}
|
||||
onClose={() => setThemeCustomizerOpen(false)}
|
||||
theme={currentTheme}
|
||||
onThemeChange={onThemeChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Convert App to a functional component to use hooks
|
||||
const App = () => {
|
||||
// @note Theme state moved to App level to provide dynamic theming
|
||||
const [currentTheme, setCurrentTheme] = useState(defaultTheme);
|
||||
const [dynamicTheme, setDynamicTheme] = useState(createTheme(defaultTheme));
|
||||
|
||||
const handleThemeChange = (newTheme) => {
|
||||
setCurrentTheme(newTheme);
|
||||
setDynamicTheme(createTheme(newTheme));
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
export { AppContent };
|
||||
59
src/PrerenderAppContent.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Box, AppBar, Toolbar, Container} from '@mui/material';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo, CategoryList } from './components/header/index.js';
|
||||
import Home from './pages/Home.js';
|
||||
|
||||
const PrerenderAppContent = (socket) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
mb: 0,
|
||||
pb: 0,
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
|
||||
|
||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
|
||||
<Toolbar sx={{ minHeight: 64 }}>
|
||||
<Container maxWidth="lg" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{/* First row: Logo and ButtonGroup on xs, all items on larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexDirection: { xs: 'column', sm: 'row' }
|
||||
}}>
|
||||
{/* Top row for xs, single row for larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: { xs: 'space-between', sm: 'flex-start' }
|
||||
}}>
|
||||
<Logo />
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Toolbar>
|
||||
<CategoryList categoryId={209} activeCategoryId={null} socket={socket}/>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
|
||||
<Footer/>
|
||||
|
||||
|
||||
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default PrerenderAppContent;
|
||||
161
src/PrerenderCategory.js
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import { Box, AppBar, Toolbar, Container, Typography, Grid, Card, CardMedia, CardContent } from '@mui/material';
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo, SearchBar, CategoryList } from './components/header/index.js';
|
||||
|
||||
const PrerenderCategory = ({ categoryId, categoryName, categorySeoName, productData }) => {
|
||||
const products = productData?.products || [];
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
mb: 0,
|
||||
pb: 0,
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
|
||||
<Toolbar sx={{ minHeight: 64 }}>
|
||||
<Container maxWidth="lg" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{/* First row: Logo and ButtonGroup on xs, all items on larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexDirection: { xs: 'column', sm: 'row' }
|
||||
}}>
|
||||
{/* Top row for xs, single row for larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: { xs: 'space-between', sm: 'flex-start' }
|
||||
}}>
|
||||
<Logo />
|
||||
{/* SearchBar visible on sm and up */}
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' }, flexGrow: 1 }}>
|
||||
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Second row: SearchBar only on xs */}
|
||||
<Box sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
width: '100%',
|
||||
mt: 1, mb: 1
|
||||
}}>
|
||||
<SearchBar />
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Toolbar>
|
||||
<CategoryList categoryId={209} activeCategoryId={categoryId} />
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="xl" sx={{ py: 2, flexGrow: 1, display: 'grid', gridTemplateRows: '1fr' }}>
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 2fr', md: '1fr 3fr', lg: '1fr 4fr', xl: '1fr 4fr' },
|
||||
gap: 3
|
||||
}}>
|
||||
<Box>
|
||||
{/* Category Info */}
|
||||
<Typography variant="h4" component="h1" sx={{ mb: 2, color: 'primary.main' }}>
|
||||
{categoryName || `Category ${categoryId}`}
|
||||
</Typography>
|
||||
|
||||
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{/* Product list */}
|
||||
<Box sx={{
|
||||
bgcolor: 'background.paper',
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
minHeight: 400
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Products {products.length > 0 && `(${products.length})`}
|
||||
</Typography>
|
||||
|
||||
{products.length > 0 ? (
|
||||
<Grid container spacing={2}>
|
||||
{products.map((product) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={product.id}>
|
||||
<a
|
||||
href={`/Artikel/${product.seoName}`}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
display: 'block',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<Card sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.1)'
|
||||
}
|
||||
}}>
|
||||
<noscript>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={product.pictureList && product.pictureList.trim()
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
|
||||
: '/assets/images/nopicture.jpg'
|
||||
}
|
||||
alt={product.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
</noscript>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" component="h3" sx={{
|
||||
mb: 1,
|
||||
fontSize: '0.9rem',
|
||||
lineHeight: 1.2,
|
||||
height: '2.4em',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}>
|
||||
{product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Art.-Nr.: {product.articleNumber}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary.main" sx={{ fontWeight: 'bold' }}>
|
||||
{product.price ? `€${parseFloat(product.price).toFixed(2)}` : 'Preis auf Anfrage'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No products found in this category
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<Footer />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrerenderCategory;
|
||||
72
src/PrerenderHome.js
Normal file
@@ -0,0 +1,72 @@
|
||||
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() {
|
||||
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 } },
|
||||
React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
|
||||
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' }
|
||||
}
|
||||
},
|
||||
React.createElement(Logo)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(CategoryList, { categoryId: 209, activeCategoryId: null })
|
||||
),
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { flexGrow: 1 } },
|
||||
React.createElement(Home)
|
||||
),
|
||||
React.createElement(Footer)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { default: PrerenderHome };
|
||||
111
src/PrerenderKonfigurator.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
AppBar,
|
||||
Toolbar
|
||||
} from '@mui/material';
|
||||
import { Logo } from './components/header/index.js';
|
||||
|
||||
class PrerenderKonfigurator extends Component {
|
||||
render() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
mb: 0,
|
||||
pb: 0,
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
<AppBar
|
||||
position="sticky"
|
||||
color="primary"
|
||||
elevation={0}
|
||||
sx={{ zIndex: 1100 }}
|
||||
>
|
||||
<Toolbar sx={{ minHeight: 64 }}>
|
||||
<Container maxWidth="lg" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Logo />
|
||||
</Container>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="lg" sx={{ py: 4, flexGrow: 1 }}>
|
||||
<Paper elevation={2} sx={{ p: 4, borderRadius: 2 }}>
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Typography variant="h3" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
🌱 Growbox Konfigurator
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Stelle dein perfektes Indoor Grow Setup zusammen
|
||||
</Typography>
|
||||
|
||||
{/* Bundle Discount Information */}
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
mt: 3,
|
||||
p: 2,
|
||||
bgcolor: '#f8f9fa',
|
||||
border: '1px solid #e9ecef',
|
||||
maxWidth: 600,
|
||||
mx: 'auto'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold', mb: 2 }}>
|
||||
🎯 Bundle-Rabatt sichern!
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-around', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ color: '#1976d2', fontWeight: 'bold' }}>
|
||||
15%
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
ab 3 Produkten
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ color: '#ed6c02', fontWeight: 'bold' }}>
|
||||
24%
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
ab 5 Produkten
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ color: '#d32f2f', fontWeight: 'bold' }}>
|
||||
36%
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
ab 7 Produkten
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#666', mt: 1, display: 'block' }}>
|
||||
Je mehr Produkte du auswählst, desto mehr sparst du!
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Section 1 Header - Only show the title and subtitle */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
1. Growbox-Form auswählen
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Wähle zuerst die Grundfläche deiner Growbox aus
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PrerenderKonfigurator;
|
||||
196
src/PrerenderProduct.js
Normal file
@@ -0,0 +1,196 @@
|
||||
const React = require('react');
|
||||
const {
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardMedia,
|
||||
Grid,
|
||||
Box,
|
||||
Chip,
|
||||
Stack,
|
||||
AppBar,
|
||||
Toolbar
|
||||
} = require('@mui/material');
|
||||
const Footer = require('./components/Footer.js').default;
|
||||
const { Logo } = require('./components/header/index.js');
|
||||
|
||||
class PrerenderProduct extends React.Component {
|
||||
render() {
|
||||
const { productData } = this.props;
|
||||
|
||||
if (!productData) {
|
||||
return React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { py: 4 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h4', component: 'h1', gutterBottom: true },
|
||||
'Product not found'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const product = productData.product;
|
||||
const attributes = productData.attributes || [];
|
||||
const mainImage = product.pictureList && product.pictureList.trim()
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
|
||||
: '/assets/images/nopicture.jpg';
|
||||
|
||||
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 } },
|
||||
React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
|
||||
React.createElement(Logo)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { py: 4, flexGrow: 1 } },
|
||||
React.createElement(
|
||||
Grid,
|
||||
{ container: true, spacing: 4 },
|
||||
// Product Image
|
||||
React.createElement(
|
||||
Grid,
|
||||
{ item: true, xs: 12, md: 6 },
|
||||
React.createElement(
|
||||
Card,
|
||||
{ sx: { height: '100%' } },
|
||||
React.createElement(
|
||||
CardMedia,
|
||||
{
|
||||
component: 'img',
|
||||
height: '400',
|
||||
image: mainImage,
|
||||
alt: product.name,
|
||||
sx: { objectFit: 'contain', p: 2 }
|
||||
}
|
||||
)
|
||||
)
|
||||
),
|
||||
// Product Details
|
||||
React.createElement(
|
||||
Grid,
|
||||
{ item: true, xs: 12, md: 6 },
|
||||
React.createElement(
|
||||
Stack,
|
||||
{ spacing: 3 },
|
||||
React.createElement(
|
||||
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}` : "")
|
||||
),
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { mt: 1 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h4', color: 'primary', fontWeight: 'bold' },
|
||||
new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(product.price)
|
||||
),
|
||||
product.vat && React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', color: 'text.secondary' },
|
||||
`inkl. ${product.vat}% MwSt.`
|
||||
),
|
||||
React.createElement(
|
||||
Typography,
|
||||
{
|
||||
variant: 'body1',
|
||||
color: product.available ? 'success.main' : 'error.main',
|
||||
fontWeight: 'medium',
|
||||
sx: { mt: 1 }
|
||||
},
|
||||
product.available ? '✅ Verfügbar' : '❌ Nicht verfügbar'
|
||||
)
|
||||
),
|
||||
product.description && React.createElement(
|
||||
Box,
|
||||
{ sx: { mt: 2 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h6', gutterBottom: true },
|
||||
'Beschreibung'
|
||||
),
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
dangerouslySetInnerHTML: { __html: product.description },
|
||||
style: {
|
||||
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.5',
|
||||
color: '#33691E'
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
// Product 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'
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(Footer)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { default: PrerenderProduct };
|
||||
41
src/PrerenderProfile.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Box,
|
||||
AppBar,
|
||||
Toolbar
|
||||
} from '@mui/material';
|
||||
import { Logo, CategoryList } from './components/header/index.js';
|
||||
|
||||
class PrerenderProfile extends Component {
|
||||
render() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
mb: 0,
|
||||
pb: 0,
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
<AppBar
|
||||
position="sticky"
|
||||
color="primary"
|
||||
elevation={0}
|
||||
sx={{ zIndex: 1100 }}
|
||||
>
|
||||
<Toolbar sx={{ minHeight: 64 }}>
|
||||
<Container maxWidth="lg" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Logo />
|
||||
</Container>
|
||||
</Toolbar>
|
||||
<CategoryList categoryId={209} activeCategoryId={null} />
|
||||
</AppBar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PrerenderProfile;
|
||||
439
src/components/AddToCartButton.js
Normal file
@@ -0,0 +1,439 @@
|
||||
import React, { Component } from "react";
|
||||
import Button from "@mui/material/Button";
|
||||
import ButtonGroup from "@mui/material/ButtonGroup";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import TextField from "@mui/material/TextField";
|
||||
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";
|
||||
|
||||
if (!Array.isArray(window.cart)) window.cart = [];
|
||||
|
||||
class AddToCartButton extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
if (!Array.isArray(window.cart)) window.cart = [];
|
||||
this.state = {
|
||||
quantity: window.cart.find((i) => i.id === this.props.id)
|
||||
? window.cart.find((i) => i.id === this.props.id).quantity
|
||||
: 0,
|
||||
isEditing: false,
|
||||
editValue: "",
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.cart = () => {
|
||||
if (!Array.isArray(window.cart)) window.cart = [];
|
||||
const item = window.cart.find((i) => i.id === this.props.id);
|
||||
const newQuantity = item ? item.quantity : 0;
|
||||
if (this.state.quantity !== newQuantity)
|
||||
this.setState({ quantity: newQuantity });
|
||||
};
|
||||
window.addEventListener("cart", this.cart);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("cart", this.cart);
|
||||
}
|
||||
|
||||
handleIncrement = () => {
|
||||
if (!window.cart) window.cart = [];
|
||||
const idx = window.cart.findIndex((item) => item.id === this.props.id);
|
||||
if (idx === -1) {
|
||||
window.cart.push({
|
||||
id: this.props.id,
|
||||
name: this.props.name,
|
||||
seoName: this.props.seoName,
|
||||
pictureList: this.props.pictureList,
|
||||
price: this.props.price,
|
||||
quantity: 1,
|
||||
weight: this.props.weight,
|
||||
vat: this.props.vat,
|
||||
versandklasse: this.props.versandklasse,
|
||||
availableSupplier: this.props.availableSupplier,
|
||||
available: this.props.available
|
||||
});
|
||||
} else {
|
||||
window.cart[idx].quantity++;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
};
|
||||
|
||||
handleDecrement = () => {
|
||||
if (!window.cart) window.cart = [];
|
||||
const idx = window.cart.findIndex((item) => item.id === this.props.id);
|
||||
if (idx !== -1) {
|
||||
if (window.cart[idx].quantity > 1) {
|
||||
window.cart[idx].quantity--;
|
||||
} else {
|
||||
window.cart.splice(idx, 1);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
}
|
||||
};
|
||||
|
||||
handleClearCart = () => {
|
||||
if (!window.cart) window.cart = [];
|
||||
const idx = window.cart.findIndex((item) => item.id === this.props.id);
|
||||
if (idx !== -1) {
|
||||
window.cart.splice(idx, 1);
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
}
|
||||
};
|
||||
|
||||
handleEditStart = () => {
|
||||
this.setState({
|
||||
isEditing: true,
|
||||
editValue: this.state.quantity > 0 ? this.state.quantity.toString() : "",
|
||||
});
|
||||
};
|
||||
|
||||
handleEditChange = (event) => {
|
||||
// Only allow numbers
|
||||
const value = event.target.value.replace(/[^0-9]/g, "");
|
||||
this.setState({ editValue: value });
|
||||
};
|
||||
|
||||
handleEditComplete = () => {
|
||||
let newQuantity = parseInt(this.state.editValue, 10);
|
||||
if (isNaN(newQuantity) || newQuantity < 0) {
|
||||
newQuantity = 0;
|
||||
}
|
||||
if (!window.cart) window.cart = [];
|
||||
const idx = window.cart.findIndex((item) => item.id === this.props.id);
|
||||
if (idx !== -1) {
|
||||
window.cart[idx].quantity = newQuantity;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("cart", {
|
||||
detail: { id: this.props.id, quantity: newQuantity },
|
||||
})
|
||||
);
|
||||
}
|
||||
this.setState({ isEditing: false });
|
||||
};
|
||||
|
||||
handleKeyPress = (event) => {
|
||||
if (event.key === "Enter") {
|
||||
this.handleEditComplete();
|
||||
}
|
||||
};
|
||||
|
||||
toggleCart = () => {
|
||||
// Dispatch an event that Header.js can listen for to toggle the cart
|
||||
window.dispatchEvent(new CustomEvent("toggle-cart"));
|
||||
};
|
||||
|
||||
render() {
|
||||
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) {
|
||||
return (
|
||||
<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
|
||||
if (quantity === 0) {
|
||||
return (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size={size || "medium"}
|
||||
onClick={this.handleIncrement}
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
backgroundColor: "#9ccc65", // yellowish green
|
||||
color: "#000000",
|
||||
"&:hover": {
|
||||
backgroundColor: "#8bc34a",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size={size || "medium"}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
"& .MuiButtonGroup-grouped:not(:last-of-type)": {
|
||||
borderRight: "1px solid rgba(255,255,255,0.3)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleDecrement}
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
px: 2,
|
||||
flexGrow: 2,
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={this.handleEditStart}
|
||||
>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
autoFocus
|
||||
value={editValue}
|
||||
onChange={this.handleEditChange}
|
||||
onBlur={this.handleEditComplete}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onFocus={(e) => e.target.select()}
|
||||
size="small"
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
style: {
|
||||
textAlign: "center",
|
||||
width: "30px",
|
||||
fontSize: "14px",
|
||||
padding: "2px",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
"aria-label": "quantity",
|
||||
}}
|
||||
sx={{ my: -0.5 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="button" sx={{ fontWeight: "bold" }}>
|
||||
{quantity}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleIncrement}
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
|
||||
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleClearCart}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "error.light" },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{this.props.cartButton && (
|
||||
<Tooltip title="Warenkorb öffnen" arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "primary.light" },
|
||||
}}
|
||||
>
|
||||
<ShoppingCartIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size={size || "medium"}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Out of Stock
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// If no items in cart, show simple "Add to Cart" button
|
||||
if (quantity === 0) {
|
||||
return (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size={size || "medium"}
|
||||
onClick={this.handleIncrement}
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
"&:hover": {
|
||||
backgroundColor: "primary.dark",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{this.props.steckling ? "Als Steckling vorbestellen" : "In den Korb"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// If items are in cart, show quantity controls
|
||||
return (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size={size || "medium"}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
"& .MuiButtonGroup-grouped:not(:last-of-type)": {
|
||||
borderRight: "1px solid rgba(255,255,255,0.3)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleDecrement}
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
px: 2,
|
||||
flexGrow: 2,
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={this.handleEditStart}
|
||||
>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
autoFocus
|
||||
value={editValue}
|
||||
onChange={this.handleEditChange}
|
||||
onBlur={this.handleEditComplete}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onFocus={(e) => e.target.select()}
|
||||
size="small"
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
style: {
|
||||
textAlign: "center",
|
||||
width: "30px",
|
||||
fontSize: "14px",
|
||||
padding: "2px",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
"aria-label": "quantity",
|
||||
}}
|
||||
sx={{ my: -0.5 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="button" sx={{ fontWeight: "bold" }}>
|
||||
{quantity}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleIncrement}
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
|
||||
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleClearCart}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "error.light" },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{this.props.cartButton && (
|
||||
<Tooltip title="Warenkorb öffnen" arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "primary.light" },
|
||||
}}
|
||||
>
|
||||
<ShoppingCartIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AddToCartButton;
|
||||
226
src/components/CartDropdown.js
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import List from '@mui/material/List';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
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 CartItem from './CartItem.js';
|
||||
|
||||
|
||||
class CartDropdown extends Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
cartItems = [],
|
||||
onClose,
|
||||
onCheckout,
|
||||
showDetailedSummary = false,
|
||||
deliveryMethod = '',
|
||||
deliveryCost = 0
|
||||
} = this.props;
|
||||
|
||||
// Calculate the total weight of all items in the cart
|
||||
const totalWeight = cartItems.reduce((sum, item) => {
|
||||
const weightPerItem = item.weight || 0;
|
||||
const quantity = item.quantity || 1;
|
||||
return sum + weightPerItem * quantity;
|
||||
}, 0);
|
||||
|
||||
// Calculate price breakdowns
|
||||
const priceCalculations = cartItems.reduce((acc, item) => {
|
||||
const totalItemPrice = item.price * item.quantity;
|
||||
const netPrice = totalItemPrice / (1 + item.vat / 100);
|
||||
const vatAmount = totalItemPrice - netPrice;
|
||||
|
||||
acc.totalGross += totalItemPrice;
|
||||
acc.totalNet += netPrice;
|
||||
|
||||
if (item.vat === 7) {
|
||||
acc.vat7 += vatAmount;
|
||||
} else if (item.vat === 19) {
|
||||
acc.vat19 += vatAmount;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
|
||||
|
||||
// Calculate detailed summary with shipping (similar to OrderSummary)
|
||||
const currencyFormatter = new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
});
|
||||
|
||||
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
|
||||
const shippingVat = deliveryCost - shippingNetPrice;
|
||||
const totalVat7 = priceCalculations.vat7;
|
||||
const totalVat19 = priceCalculations.vat19 + shippingVat;
|
||||
const totalGross = priceCalculations.totalGross + deliveryCost;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ bgcolor: 'primary.main', color: 'white', p: 2 }}>
|
||||
<Typography variant="h6">
|
||||
{cartItems.length} {cartItems.length === 1 ? 'Produkt' : 'Produkte'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{ cartItems && (
|
||||
<>
|
||||
<List sx={{ width: '100%' }}>
|
||||
{cartItems.map((item) => (
|
||||
<CartItem
|
||||
key={item.id}
|
||||
socket={this.props.socket}
|
||||
item={item}
|
||||
id={item.id}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{/* Display total weight if greater than 0 */}
|
||||
{totalWeight > 0 && (
|
||||
<Typography variant="subtitle2" sx={{ px: 2, mb: 1 }}>
|
||||
Gesamtgewicht: {totalWeight.toFixed(2)} kg
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Price breakdown table */}
|
||||
{cartItems.length > 0 && (
|
||||
<Box sx={{ px: 2, mb: 2 }}>
|
||||
{showDetailedSummary ? (
|
||||
// Detailed summary with shipping costs
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
Bestellübersicht
|
||||
</Typography>
|
||||
{deliveryMethod && (
|
||||
<Typography variant="body2" sx={{ mb: 1, color: 'text.secondary' }}>
|
||||
Versandart: {deliveryMethod}
|
||||
</Typography>
|
||||
)}
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Waren (netto):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(priceCalculations.totalNet)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>Versandkosten (netto):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(shippingNetPrice)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{totalVat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat7)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{totalVat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat19)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{currencyFormatter.format(priceCalculations.totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>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 }}>Gesamtsumme:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
||||
{currencyFormatter.format(totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
) : (
|
||||
// Simple summary without shipping costs
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<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>7% Mehrwertsteuer:</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat7)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{priceCalculations.vat19 > 0 && (
|
||||
<TableRow>
|
||||
<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' }}>Gesamtbruttopreis ohne Versand:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={onClose}
|
||||
>
|
||||
Weiter einkaufen
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onCheckout && cartItems.length > 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
onClick={onCheckout}
|
||||
>
|
||||
Weiter zur Kasse
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CartDropdown;
|
||||
162
src/components/CartItem.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { Component } from 'react';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AddToCartButton from './AddToCartButton.js';
|
||||
|
||||
class CartItem extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
if (!window.tinyPicCache) {
|
||||
window.tinyPicCache = {};
|
||||
}
|
||||
if(this.props.item && this.props.item.pictureList && this.props.item.pictureList.split(',').length > 0) {
|
||||
const picid = this.props.item.pictureList.split(',')[0];
|
||||
if(window.tinyPicCache[picid]){
|
||||
this.setState({image:window.tinyPicCache[picid],loading:false, error: false})
|
||||
}else{
|
||||
this.setState({image: null, loading: true, error: false});
|
||||
if(this.props.socket){
|
||||
this.props.socket.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
|
||||
if(res.success){
|
||||
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
this.setState({image: window.tinyPicCache[picid], loading: false});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleIncrement = () => {
|
||||
const { item, onQuantityChange } = this.props;
|
||||
onQuantityChange(item.quantity + 1);
|
||||
};
|
||||
|
||||
handleDecrement = () => {
|
||||
const { item, onQuantityChange } = this.props;
|
||||
if (item.quantity > 1) {
|
||||
onQuantityChange(item.quantity - 1);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { item } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
alignItems="flex-start"
|
||||
sx={{ py: 2, width: '100%' }}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
alt={item.name}
|
||||
src={this.state?.image}
|
||||
sx={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
mr: 2,
|
||||
bgcolor: 'primary.light',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, width: '100%' }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="div"
|
||||
sx={{ fontWeight: 'bold', mb: 0.5 }}
|
||||
>
|
||||
<Link to={`/Artikel/${item.seoName}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
{item.name}
|
||||
</Link>
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1, mt: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
component="div"
|
||||
>
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(item.price)} x {item.quantity}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="primary.dark"
|
||||
fontWeight="bold"
|
||||
component="div"
|
||||
>
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(item.price * item.quantity)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Weight and VAT display - conditional layout based on weight */}
|
||||
{(item.weight > 0 || item.vat) && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: item.weight > 0 || (item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos') ? 'space-between' : 'flex-end',
|
||||
mb: 1
|
||||
}}>
|
||||
{item.weight > 0 && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
component="div"
|
||||
>
|
||||
{item.weight.toFixed(1).replace('.',',')} kg
|
||||
</Typography>
|
||||
)}
|
||||
{item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos' && (
|
||||
<Typography variant="body2" color="warning.main" fontWeight="medium" component="div">
|
||||
{item.versandklasse}
|
||||
</Typography>
|
||||
)}
|
||||
{item.vat && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
fontStyle="italic"
|
||||
component="div"
|
||||
>
|
||||
inkl. {new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(
|
||||
(item.price * item.quantity) - ((item.price * item.quantity) / (1 + item.vat / 100))
|
||||
)} MwSt. ({item.vat}%)
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ width: '250px'}}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
mb: 1,
|
||||
display: "block"
|
||||
}}
|
||||
>
|
||||
{this.props.id.toString().endsWith("steckling") ? "Lieferzeit: 14 Tage" :
|
||||
item.available == 1 ? "Lieferzeit: 2-3 Tage" :
|
||||
item.availableSupplier == 1 ? "Lieferzeit: 7-9 Tage" : ""}
|
||||
</Typography>
|
||||
<AddToCartButton available={1} id={this.props.id} availableSupplier={item.availableSupplier} price={item.price} seoName={item.seoName} name={item.name} weight={item.weight} vat={item.vat} versandklasse={item.versandklasse}/>
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CartItem;
|
||||
117
src/components/CartSyncDialog.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useState } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import RadioGroup from '@mui/material/RadioGroup';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
const CartSyncDialog = ({ open, localCart = [], serverCart = [], onClose, onConfirm }) => {
|
||||
const [option, setOption] = useState('merge');
|
||||
|
||||
// Helper function to determine if an item is selected in the result
|
||||
const isItemSelected = (item, cart, isResultCart = false) => {
|
||||
if (isResultCart) return true; // All items in result cart are selected
|
||||
|
||||
switch (option) {
|
||||
case 'deleteServer':
|
||||
return cart === localCart;
|
||||
case 'useServer':
|
||||
return cart === serverCart;
|
||||
case 'merge':
|
||||
return true; // Both carts contribute to merge
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const renderCartItem = (item, cart, isResultCart = false) => {
|
||||
const selected = isItemSelected(item, cart, isResultCart);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
sx={{
|
||||
opacity: selected ? 1 : 0.4,
|
||||
backgroundColor: selected ? 'action.selected' : 'transparent',
|
||||
borderRadius: 1,
|
||||
mb: 0.5
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{item.name} x {item.quantity}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
|
||||
<DialogTitle>Warenkorb-Synchronisierung</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography paragraph>
|
||||
Sie haben einen gespeicherten Warenkorb in ihrem Account. Bitte wählen Sie, wie Sie verfahren möchten:
|
||||
</Typography>
|
||||
<RadioGroup value={option} onChange={e => setOption(e.target.value)}>
|
||||
{/*<FormControlLabel
|
||||
value="useLocalArchive"
|
||||
control={<Radio />}
|
||||
label="Lokalen Warenkorb verwenden und Serverseitigen Warenkorb archivieren"
|
||||
/>*/}
|
||||
<FormControlLabel
|
||||
value="deleteServer"
|
||||
control={<Radio />}
|
||||
label="Server-Warenkorb löschen"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="useServer"
|
||||
control={<Radio />}
|
||||
label="Server-Warenkorb übernehmen"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="merge"
|
||||
control={<Radio />}
|
||||
label="Warenkörbe zusammenführen"
|
||||
/>
|
||||
</RadioGroup>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="h6">Ihr aktueller Warenkorb</Typography>
|
||||
<List sx={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
{localCart.length > 0
|
||||
? localCart.map(item => renderCartItem(item, localCart))
|
||||
: <Typography color="text.secondary" sx={{ p: 2 }}>leer</Typography>}
|
||||
</List>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="h6">In Ihrem Profil gespeicherter Warenkorb</Typography>
|
||||
<List sx={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
{serverCart.length > 0
|
||||
? serverCart.map(item => renderCartItem(item, serverCart))
|
||||
: <Typography color="text.secondary" sx={{ p: 2 }}>leer</Typography>}
|
||||
</List>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Abbrechen</Button>
|
||||
<Button variant="contained" onClick={() => onConfirm(option)}>
|
||||
Weiter
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartSyncDialog;
|
||||
201
src/components/CategoryBox.js
Normal file
@@ -0,0 +1,201 @@
|
||||
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
|
||||
|
||||
// Initialize cache in window object if it doesn't exist
|
||||
if (typeof window !== 'undefined' && !window.categoryImageCache) {
|
||||
window.categoryImageCache = new Map();
|
||||
}
|
||||
|
||||
const CategoryBox = ({
|
||||
id,
|
||||
name,
|
||||
seoName,
|
||||
bgcolor,
|
||||
fontSize = '0.8rem',
|
||||
...props
|
||||
}) => {
|
||||
const [imageUrl, setImageUrl] = useState(null);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const socket = useContext(SocketContext);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl = null;
|
||||
|
||||
// Skip image loading entirely if prerender fallback is active
|
||||
// @note Check both browser and SSR environments for prerender flag
|
||||
const isPrerenderFallback = (typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
|
||||
(typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__);
|
||||
|
||||
if (isPrerenderFallback) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have the image data cached first
|
||||
if (typeof window !== 'undefined' && window.categoryImageCache.has(id)) {
|
||||
const cachedImageData = window.categoryImageCache.get(id);
|
||||
if (cachedImageData === null) {
|
||||
// @note Cached as null - this category has no image
|
||||
setImageUrl(null);
|
||||
setImageError(false);
|
||||
} else {
|
||||
// Create fresh blob URL from cached binary data
|
||||
try {
|
||||
const uint8Array = new Uint8Array(cachedImageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
} catch (error) {
|
||||
console.error('Error creating blob URL from cached data:', error);
|
||||
setImageError(true);
|
||||
setImageUrl(null);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If socket is available and connected, fetch the image
|
||||
if (socket && socket.connected && id && !isLoading) {
|
||||
setIsLoading(true);
|
||||
|
||||
socket.emit('getCategoryPic', { categoryId: id }, (response) => {
|
||||
setIsLoading(false);
|
||||
|
||||
if (response.success) {
|
||||
const imageData = response.image; // Binary image data or null
|
||||
|
||||
if (imageData) {
|
||||
try {
|
||||
// Convert binary data to blob URL
|
||||
const uint8Array = new Uint8Array(imageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
|
||||
// @note Cache the raw binary data in window object (not the blob URL)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.categoryImageCache.set(id, imageData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting image data to URL:', error);
|
||||
setImageError(true);
|
||||
setImageUrl(null);
|
||||
// Cache as null to avoid repeated requests
|
||||
if (typeof window !== 'undefined') {
|
||||
window.categoryImageCache.set(id, null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// @note No image available for this category
|
||||
setImageUrl(null);
|
||||
setImageError(false);
|
||||
// Cache as null so we don't keep requesting
|
||||
if (typeof window !== 'undefined') {
|
||||
window.categoryImageCache.set(id, null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Error fetching category image:', response.error);
|
||||
setImageError(true);
|
||||
setImageUrl(null);
|
||||
// Cache as null to avoid repeated failed requests
|
||||
if (typeof window !== 'undefined') {
|
||||
window.categoryImageCache.set(id, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up the object URL when component unmounts or image changes
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [socket, socket?.connected, id, isLoading]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
component={Link}
|
||||
to={`/Kategorie/${seoName}`}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
width: '130px',
|
||||
height: '130px',
|
||||
minHeight: '130px',
|
||||
minWidth: '130px',
|
||||
maxWidth: '130px',
|
||||
maxHeight: '130px',
|
||||
display: 'block',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
backgroundColor: bgcolor || '#f0f0f0',
|
||||
boxShadow: '0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)'
|
||||
}}
|
||||
sx={{
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: 8
|
||||
},
|
||||
...props.sx
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* Main content area - using flex to fill space */}
|
||||
<Box sx={{
|
||||
width: '130px',
|
||||
height: '130px',
|
||||
bgcolor: bgcolor || '#e0e0e0',
|
||||
position: 'relative',
|
||||
backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
|
||||
(typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__))
|
||||
? `url("/assets/images/cat${id}.jpg")`
|
||||
: (imageUrl && !imageError ? `url("${imageUrl}")` : 'none'),
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat'
|
||||
}}>
|
||||
|
||||
{/* Category name at bottom */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '0px',
|
||||
left: '0px',
|
||||
width: '130px',
|
||||
height: '40px',
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
display: 'table',
|
||||
tableLayout: 'fixed'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'table-cell',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
color: 'white',
|
||||
fontSize: fontSize,
|
||||
fontFamily: 'SwashingtonCP, "Times New Roman", Georgia, serif',
|
||||
fontWeight: 'normal',
|
||||
lineHeight: '1.2',
|
||||
padding: '0 8px'
|
||||
}}>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryBox;
|
||||
68
src/components/CategoryBoxGrid.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import CategoryBox from './CategoryBox.js';
|
||||
|
||||
// @note SwashingtonCP font is now loaded globally via index.css
|
||||
|
||||
const CategoryBoxGrid = ({
|
||||
categories = [],
|
||||
title,
|
||||
spacing = 3,
|
||||
showTitle = true,
|
||||
titleVariant = 'h3',
|
||||
titleSx = {},
|
||||
gridProps = {},
|
||||
boxProps = {}
|
||||
}) => {
|
||||
if (!categories || categories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Optional title */}
|
||||
{showTitle && title && (
|
||||
<Typography
|
||||
variant={titleVariant}
|
||||
component="h1"
|
||||
sx={{
|
||||
mb: 4,
|
||||
fontFamily: 'SwashingtonCP',
|
||||
color: 'primary.main',
|
||||
textAlign: 'center',
|
||||
...titleSx
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Category boxes grid */}
|
||||
<Grid container spacing={spacing} sx={{ mt: showTitle && title ? 0 : 2, ...gridProps.sx }} {...gridProps}>
|
||||
{categories.map((category) => (
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
lg={3}
|
||||
key={category.id}
|
||||
>
|
||||
<CategoryBox
|
||||
id={category.id}
|
||||
name={category.name}
|
||||
seoName={category.seoName}
|
||||
image={category.image}
|
||||
bgcolor={category.bgcolor}
|
||||
{...boxProps}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryBoxGrid;
|
||||
664
src/components/ChatAssistant.js
Normal file
@@ -0,0 +1,664 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
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 { isUserLoggedIn } from './LoginComponent.js';
|
||||
// Initialize window object for storing messages
|
||||
if (!window.chatMessages) {
|
||||
window.chatMessages = [];
|
||||
}
|
||||
|
||||
class ChatAssistant extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const privacyConfirmed = sessionStorage.getItem('privacyConfirmed') === 'true';
|
||||
|
||||
this.state = {
|
||||
messages: window.chatMessages,
|
||||
inputValue: '',
|
||||
isTyping: false,
|
||||
isRecording: false,
|
||||
recordingTime: 0,
|
||||
mediaRecorder: null,
|
||||
audioChunks: [],
|
||||
aiThink: false,
|
||||
atDatabase: false,
|
||||
atWeb: false,
|
||||
privacyConfirmed: privacyConfirmed,
|
||||
isGuest: false
|
||||
};
|
||||
|
||||
this.messagesEndRef = React.createRef();
|
||||
this.fileInputRef = React.createRef();
|
||||
this.recordingTimer = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Add socket listeners if socket is available and connected
|
||||
this.addSocketListeners();
|
||||
|
||||
const userStatus = isUserLoggedIn();
|
||||
const isGuest = !userStatus.isLoggedIn;
|
||||
|
||||
if (isGuest && !this.state.privacyConfirmed) {
|
||||
this.setState(prevState => {
|
||||
if (prevState.messages.find(msg => msg.id === 'privacy-prompt')) {
|
||||
return { isGuest: true };
|
||||
}
|
||||
|
||||
const privacyMessage = {
|
||||
id: 'privacy-prompt',
|
||||
sender: 'bot',
|
||||
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;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isGuest: true
|
||||
};
|
||||
});
|
||||
} else {
|
||||
this.setState({ isGuest });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
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.removeSocketListeners();
|
||||
this.stopRecording();
|
||||
if (this.recordingTimer) {
|
||||
clearInterval(this.recordingTimer);
|
||||
}
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('aiassyResponse', this.handleBotResponse);
|
||||
this.props.socket.on('aiassyStatus', this.handleStateResponse);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('aiassyResponse', this.handleBotResponse);
|
||||
this.props.socket.off('aiassyStatus', this.handleStateResponse);
|
||||
}
|
||||
}
|
||||
|
||||
handleBotResponse = (msgId,response) => {
|
||||
this.setState(prevState => {
|
||||
// Check if a message with this msgId already exists
|
||||
const existingMessageIndex = prevState.messages.findIndex(msg => msg.msgId === msgId);
|
||||
|
||||
let updatedMessages;
|
||||
|
||||
if (existingMessageIndex !== -1 && msgId) {
|
||||
// If message with this msgId exists, append the response
|
||||
updatedMessages = [...prevState.messages];
|
||||
updatedMessages[existingMessageIndex] = {
|
||||
...updatedMessages[existingMessageIndex],
|
||||
text: updatedMessages[existingMessageIndex].text + response.content
|
||||
};
|
||||
} else {
|
||||
// Create a new message
|
||||
console.log('ChatAssistant: handleBotResponse', msgId, response);
|
||||
if(response && response.content) {
|
||||
const newBotMessage = {
|
||||
id: Date.now(),
|
||||
msgId: msgId,
|
||||
sender: 'bot',
|
||||
text: response.content,
|
||||
};
|
||||
updatedMessages = [...prevState.messages, newBotMessage];
|
||||
}
|
||||
}
|
||||
|
||||
// Store in window object
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isTyping: false
|
||||
};
|
||||
});
|
||||
}
|
||||
handleStateResponse = (msgId,response) => {
|
||||
if(response == 'think') this.setState({ aiThink: true });
|
||||
if(response == 'nothink') this.setState({ aiThink: false });
|
||||
if(response == 'database') this.setState({ atDatabase: true });
|
||||
if(response == 'nodatabase') this.setState({ atDatabase: false });
|
||||
if(response == 'web') this.setState({ atWeb: true });
|
||||
if(response == 'noweb') this.setState({ atWeb: false });
|
||||
}
|
||||
|
||||
scrollToBottom = () => {
|
||||
this.messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
handleInputChange = (event) => {
|
||||
this.setState({ inputValue: event.target.value });
|
||||
}
|
||||
|
||||
handleSendMessage = () => {
|
||||
const userMessage = this.state.inputValue.trim();
|
||||
if (!userMessage) return;
|
||||
|
||||
const newUserMessage = {
|
||||
id: Date.now(),
|
||||
sender: 'user',
|
||||
text: userMessage,
|
||||
};
|
||||
|
||||
// Update messages in component state
|
||||
this.setState(prevState => {
|
||||
const updatedMessages = [...prevState.messages, newUserMessage];
|
||||
// Store in window object
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
inputValue: '',
|
||||
isTyping: true
|
||||
};
|
||||
}, () => {
|
||||
// Emit message to socket server after state is updated
|
||||
if (userMessage.trim() && this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyMessage', userMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleKeyDown = (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
this.handleSendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
const audioChunks = [];
|
||||
|
||||
mediaRecorder.addEventListener("dataavailable", event => {
|
||||
audioChunks.push(event.data);
|
||||
});
|
||||
|
||||
mediaRecorder.addEventListener("stop", () => {
|
||||
if (audioChunks.length > 0) {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
|
||||
this.sendAudioMessage(audioBlob);
|
||||
}
|
||||
|
||||
// Stop all tracks on the stream to release the microphone
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
});
|
||||
|
||||
// Start recording
|
||||
mediaRecorder.start();
|
||||
|
||||
// Set up timer - limit to 60 seconds
|
||||
this.recordingTimer = setInterval(() => {
|
||||
this.setState(prevState => {
|
||||
const newTime = prevState.recordingTime + 1;
|
||||
|
||||
// Auto-stop after 10 seconds
|
||||
if (newTime >= 10) {
|
||||
this.stopRecording();
|
||||
}
|
||||
|
||||
return { recordingTime: newTime };
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
this.setState({
|
||||
isRecording: true,
|
||||
mediaRecorder,
|
||||
audioChunks,
|
||||
recordingTime: 0
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error accessing microphone:", err);
|
||||
alert("Could not access microphone. Please check your browser permissions.");
|
||||
}
|
||||
};
|
||||
|
||||
stopRecording = () => {
|
||||
const { mediaRecorder, isRecording } = this.state;
|
||||
|
||||
if (this.recordingTimer) {
|
||||
clearInterval(this.recordingTimer);
|
||||
}
|
||||
|
||||
if (mediaRecorder && isRecording) {
|
||||
mediaRecorder.stop();
|
||||
this.setState({
|
||||
isRecording: false,
|
||||
recordingTime: 0
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
sendAudioMessage = async (audioBlob) => {
|
||||
// Create a URL for the audio blob
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
// Create a user message with audio content
|
||||
const newUserMessage = {
|
||||
id: Date.now(),
|
||||
sender: 'user',
|
||||
text: `<audio controls src="${audioUrl}"></audio>`,
|
||||
isAudio: true
|
||||
};
|
||||
|
||||
// Update UI with the audio message
|
||||
this.setState(prevState => {
|
||||
const updatedMessages = [...prevState.messages, newUserMessage];
|
||||
// Store in window object
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isTyping: true
|
||||
};
|
||||
});
|
||||
|
||||
// Convert audio to base64 for sending to server
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(audioBlob);
|
||||
reader.onloadend = () => {
|
||||
const base64Audio = reader.result.split(',')[1];
|
||||
// Send audio data to server
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyAudioMessage', {
|
||||
audio: base64Audio,
|
||||
format: 'wav'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
handleImageUpload = () => {
|
||||
this.fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
handleFileChange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
this.resizeAndSendImage(file);
|
||||
}
|
||||
// Reset the file input
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
resizeAndSendImage = (file) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// Calculate new dimensions (max 450px width/height)
|
||||
const maxSize = 450;
|
||||
let { width, height } = img;
|
||||
|
||||
if (width > height) {
|
||||
if (width > maxSize) {
|
||||
height *= maxSize / width;
|
||||
width = maxSize;
|
||||
}
|
||||
} else {
|
||||
if (height > maxSize) {
|
||||
width *= maxSize / height;
|
||||
height = maxSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Set canvas dimensions
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Draw and compress image
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Convert to blob with compression
|
||||
canvas.toBlob((blob) => {
|
||||
this.sendImageMessage(blob);
|
||||
}, 'image/jpeg', 0.8);
|
||||
};
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
};
|
||||
|
||||
sendImageMessage = async (imageBlob) => {
|
||||
// Create a URL for the image blob
|
||||
const imageUrl = URL.createObjectURL(imageBlob);
|
||||
|
||||
// Create a user message with image content
|
||||
const newUserMessage = {
|
||||
id: Date.now(),
|
||||
sender: 'user',
|
||||
text: `<img src="${imageUrl}" alt="Uploaded image" style="max-width: 100%; height: auto; border-radius: 8px;" />`,
|
||||
isImage: true
|
||||
};
|
||||
|
||||
// Update UI with the image message
|
||||
this.setState(prevState => {
|
||||
const updatedMessages = [...prevState.messages, newUserMessage];
|
||||
// Store in window object
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isTyping: true
|
||||
};
|
||||
});
|
||||
|
||||
// Convert image to base64 for sending to server
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(imageBlob);
|
||||
reader.onloadend = () => {
|
||||
const base64Image = reader.result.split(',')[1];
|
||||
// Send image data to server
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyPicMessage', {
|
||||
image: base64Image,
|
||||
format: 'jpeg'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
formatTime = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
||||
};
|
||||
|
||||
handlePrivacyConfirm = () => {
|
||||
sessionStorage.setItem('privacyConfirmed', 'true');
|
||||
this.setState(prevState => {
|
||||
const updatedMessages = prevState.messages.filter(msg => msg.id !== 'privacy-prompt');
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
privacyConfirmed: true,
|
||||
messages: updatedMessages
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
formatMarkdown = (text) => {
|
||||
// Replace code blocks with formatted HTML
|
||||
return text.replace(/```(.*?)\n([\s\S]*?)```/g, (match, language, code) => {
|
||||
return `<pre class="code-block" data-language="${language.trim()}"><code>${code.trim()}</code></pre>`;
|
||||
});
|
||||
};
|
||||
|
||||
getParseOptions = () => ({
|
||||
replace: (domNode) => {
|
||||
// Convert <a> tags to React Router Links
|
||||
if (domNode.name === 'a' && domNode.attribs && domNode.attribs.href) {
|
||||
const href = domNode.attribs.href;
|
||||
|
||||
// Only convert internal links (not external URLs)
|
||||
if (!href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('//')) {
|
||||
return (
|
||||
<Link to={href} style={{ color: 'inherit', textDecoration: 'underline' }}>
|
||||
{domToReact(domNode.children, this.getParseOptions())}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Style pre/code blocks
|
||||
if (domNode.name === 'pre' && domNode.attribs && domNode.attribs.class === 'code-block') {
|
||||
const language = domNode.attribs['data-language'] || '';
|
||||
return (
|
||||
<pre style={{
|
||||
backgroundColor: '#c0f5c0',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
overflowX: 'auto',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.9em',
|
||||
whiteSpace: 'pre-wrap',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
{language && <div style={{ marginBottom: '4px', color: '#666' }}>{language}</div>}
|
||||
{domToReact(domNode.children, this.getParseOptions())}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (domNode.name === 'button' && domNode.attribs && domNode.attribs['data-confirm-privacy']) {
|
||||
return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>Gelesen & Akzeptiert</Button>;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
render() {
|
||||
const { open, onClose } = this.props;
|
||||
const { messages, inputValue, isTyping, isRecording, recordingTime, isGuest, privacyConfirmed } = this.state;
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputsDisabled = isGuest && !privacyConfirmed;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: { xs: 16, sm: 80 },
|
||||
right: { xs: 16, sm: 16 },
|
||||
left: { xs: 16, sm: 'auto' },
|
||||
top: { xs: 16, sm: 'auto' },
|
||||
width: { xs: 'calc(100vw - 32px)', sm: 450, md: 600, lg: 750 },
|
||||
height: { xs: 'calc(100vh - 32px)', sm: 600, md: 650, lg: 700 },
|
||||
maxWidth: { xs: 'none', sm: 450, md: 600, lg: 750 },
|
||||
maxHeight: { xs: 'calc(100vh - 72px)', sm: 600, md: 650, lg: 700 },
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 1300,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
borderTopLeftRadius: 'inherit',
|
||||
borderTopRightRadius: 'inherit',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="div">
|
||||
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" sx={{ color: 'primary.contrastText' }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{messages &&messages.map((message) => (
|
||||
<Box
|
||||
key={message.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: message.sender === 'user' ? 'flex-end' : 'flex-start',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{message.sender === 'bot' && (
|
||||
<Avatar sx={{ bgcolor: 'primary.main', width: 30, height: 30 }}>
|
||||
<SmartToyIcon fontSize="small" />
|
||||
</Avatar>
|
||||
)}
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
py: 1,
|
||||
px: 3,
|
||||
borderRadius: 2,
|
||||
bgcolor: message.sender === 'user' ? 'secondary.light' : 'grey.200',
|
||||
maxWidth: '75%',
|
||||
fontSize: '0.8em'
|
||||
}}
|
||||
>
|
||||
{message.text ? parse(this.formatMarkdown(message.text), this.getParseOptions()) : ''}
|
||||
</Paper>
|
||||
{message.sender === 'user' && (
|
||||
<Avatar sx={{ bgcolor: 'secondary.main', width: 30, height: 30 }}>
|
||||
<PersonIcon fontSize="small" />
|
||||
</Avatar>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{isTyping && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar sx={{ bgcolor: 'primary.main', width: 30, height: 30 }}>
|
||||
<SmartToyIcon fontSize="small" />
|
||||
</Avatar>
|
||||
<Paper elevation={1} sx={{ p: 1, borderRadius: 2, bgcolor: 'grey.200', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<CircularProgress size={16} sx={{ mx: 1 }} />
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
<div ref={this.messagesEndRef} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
p: 1,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={this.fileInputRef}
|
||||
accept="image/*"
|
||||
onChange={this.handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
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,
|
||||
endAdornment: isRecording && (
|
||||
<Typography variant="caption" color="primary" sx={{ mr: 1 }}>
|
||||
{this.formatTime(recordingTime)}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{isRecording ? (
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={this.stopRecording}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<StopIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={this.startRecording}
|
||||
sx={{ ml: 1 }}
|
||||
disabled={isTyping || inputsDisabled}
|
||||
>
|
||||
<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 ChatAssistant;
|
||||
681
src/components/Content.js
Normal file
@@ -0,0 +1,681 @@
|
||||
import React, { Component } from 'react';
|
||||
import Container from '@mui/material/Container';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Link } from 'react-router-dom';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import ProductFilters from './ProductFilters.js';
|
||||
import ProductList from './ProductList.js';
|
||||
import CategoryBoxGrid from './CategoryBoxGrid.js';
|
||||
import CategoryBox from './CategoryBox.js';
|
||||
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// @note SwashingtonCP font is now loaded globally via index.css
|
||||
|
||||
const withRouter = (ClassComponent) => {
|
||||
return (props) => {
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
return <ClassComponent {...props} params={params} searchParams={searchParams} />;
|
||||
};
|
||||
};
|
||||
|
||||
function getCachedCategoryData(categoryId) {
|
||||
if (!window.productCache) {
|
||||
window.productCache = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `categoryProducts_${categoryId}`;
|
||||
const cachedData = window.productCache[cacheKey];
|
||||
|
||||
if (cachedData) {
|
||||
const { timestamp } = cachedData;
|
||||
const cacheAge = Date.now() - timestamp;
|
||||
const tenMinutes = 10 * 60 * 1000;
|
||||
if (cacheAge < tenMinutes) {
|
||||
return cachedData;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error reading from cache:', err);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function getFilteredProducts(unfilteredProducts, attributes) {
|
||||
const attributeSettings = getAllSettingsWithPrefix('filter_attribute_');
|
||||
const manufacturerSettings = getAllSettingsWithPrefix('filter_manufacturer_');
|
||||
const availabilitySettings = getAllSettingsWithPrefix('filter_availability_');
|
||||
|
||||
const attributeFilters = [];
|
||||
Object.keys(attributeSettings).forEach(key => {
|
||||
if (attributeSettings[key] === 'true') {
|
||||
attributeFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
const manufacturerFilters = [];
|
||||
Object.keys(manufacturerSettings).forEach(key => {
|
||||
if (manufacturerSettings[key] === 'true') {
|
||||
manufacturerFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
const availabilityFilters = [];
|
||||
Object.keys(availabilitySettings).forEach(key => {
|
||||
if (availabilitySettings[key] === 'true') {
|
||||
availabilityFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueAttributes = [...new Set((attributes || []).map(attr => attr.kMerkmalWert ? attr.kMerkmalWert.toString() : ''))];
|
||||
const uniqueManufacturers = [...new Set((unfilteredProducts || []).filter(product => product.manufacturerId).map(product => product.manufacturerId ? product.manufacturerId.toString() : ''))];
|
||||
const uniqueManufacturersWithName = [...new Set((unfilteredProducts || []).filter(product => product.manufacturerId).map(product => ({id:product.manufacturerId ? product.manufacturerId.toString() : '',value:product.manufacturer})))];
|
||||
const activeAttributeFilters = attributeFilters.filter(filter => uniqueAttributes.includes(filter));
|
||||
const activeManufacturerFilters = manufacturerFilters.filter(filter => uniqueManufacturers.includes(filter));
|
||||
const attributeFiltersByGroup = {};
|
||||
for (const filterId of activeAttributeFilters) {
|
||||
const attribute = attributes.find(attr => attr.kMerkmalWert.toString() === filterId);
|
||||
if (attribute) {
|
||||
if (!attributeFiltersByGroup[attribute.cName]) {
|
||||
attributeFiltersByGroup[attribute.cName] = [];
|
||||
}
|
||||
attributeFiltersByGroup[attribute.cName].push(filterId);
|
||||
}
|
||||
}
|
||||
|
||||
let filteredProducts = (unfilteredProducts || []).filter(product => {
|
||||
const availabilityFilter = sessionStorage.getItem('filter_availability');
|
||||
let inStockMatch = availabilityFilter == 1 ? true : (product.available>0);
|
||||
const isNewMatch = availabilityFilters.includes('2') ? isNew(product.neu) : true;
|
||||
let soonMatch = availabilityFilters.includes('3') ? !product.available && product.incoming : true;
|
||||
|
||||
const soon2Match = (availabilityFilter != 1)&&availabilityFilters.includes('3') ? (product.available) || (!product.available && product.incoming) : true;
|
||||
if( (availabilityFilter != 1)&&availabilityFilters.includes('3') && ((product.available) || (!product.available && product.incoming))){
|
||||
inStockMatch = true;
|
||||
soonMatch = true;
|
||||
console.log("soon2Match", product.cName);
|
||||
}
|
||||
|
||||
const manufacturerMatch = activeManufacturerFilters.length === 0 ||
|
||||
|
||||
(product.manufacturerId && activeManufacturerFilters.includes(product.manufacturerId.toString()));
|
||||
if (Object.keys(attributeFiltersByGroup).length === 0) {
|
||||
return manufacturerMatch && soon2Match && inStockMatch && soonMatch && isNewMatch;
|
||||
}
|
||||
const productAttributes = attributes
|
||||
.filter(attr => attr.kArtikel === product.id);
|
||||
const attributeMatch = Object.entries(attributeFiltersByGroup).every(([groupName, groupFilters]) => {
|
||||
const productGroupAttributes = productAttributes
|
||||
.filter(attr => attr.cName === groupName)
|
||||
.map(attr => attr.kMerkmalWert ? attr.kMerkmalWert.toString() : '');
|
||||
return groupFilters.some(filter => productGroupAttributes.includes(filter));
|
||||
});
|
||||
return manufacturerMatch && attributeMatch && soon2Match && inStockMatch && soonMatch && isNewMatch;
|
||||
});
|
||||
|
||||
|
||||
const activeAttributeFiltersWithNames = activeAttributeFilters.map(filter => {
|
||||
const attribute = attributes.find(attr => attr.kMerkmalWert.toString() === filter);
|
||||
return {name: attribute.cName, value: attribute.cWert, id: attribute.kMerkmalWert};
|
||||
});
|
||||
const activeManufacturerFiltersWithNames = activeManufacturerFilters.map(filter => {
|
||||
const manufacturer = uniqueManufacturersWithName.find(manufacturer => manufacturer.id === filter);
|
||||
return {name: manufacturer.value, value: manufacturer.id};
|
||||
});
|
||||
return {filteredProducts,activeAttributeFilters:activeAttributeFiltersWithNames,activeManufacturerFilters:activeManufacturerFiltersWithNames};
|
||||
}
|
||||
function setCachedCategoryData(categoryId, data) {
|
||||
if (!window.productCache) {
|
||||
window.productCache = {};
|
||||
}
|
||||
if (!window.productDetailCache) {
|
||||
window.productDetailCache = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `categoryProducts_${categoryId}`;
|
||||
if(data.products) for(const product of data.products) {
|
||||
window.productDetailCache[product.id] = product;
|
||||
}
|
||||
window.productCache[cacheKey] = {
|
||||
...data,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error writing to cache:', err);
|
||||
}
|
||||
}
|
||||
|
||||
class Content extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loaded: false,
|
||||
categoryName: null,
|
||||
unfilteredProducts: [],
|
||||
filteredProducts: [],
|
||||
attributes: [],
|
||||
childCategories: []
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if(this.props.params.categoryId) {this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
})}
|
||||
else if (this.props.searchParams?.get('q')) {
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if(this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId)) {
|
||||
window.currentSearchQuery = null;
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
});
|
||||
}
|
||||
else if (this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'))) {
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
})
|
||||
}
|
||||
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected && !this.state.loaded) {
|
||||
// Socket just connected and we haven't loaded data yet, retry loading
|
||||
if (this.props.params.categoryId) {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
} else if (this.props.searchParams?.get('q')) {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processData(response) {
|
||||
const unfilteredProducts = response.products;
|
||||
|
||||
if (!window.individualProductCache) {
|
||||
window.individualProductCache = {};
|
||||
}
|
||||
//console.log("processData", unfilteredProducts);
|
||||
if(unfilteredProducts) unfilteredProducts.forEach(product => {
|
||||
window.individualProductCache[product.id] = {
|
||||
data: product,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
});
|
||||
|
||||
this.setState({
|
||||
unfilteredProducts: unfilteredProducts,
|
||||
...getFilteredProducts(
|
||||
unfilteredProducts,
|
||||
response.attributes
|
||||
),
|
||||
categoryName: response.categoryName || response.name || null,
|
||||
dataType: response.dataType,
|
||||
dataParam: response.dataParam,
|
||||
attributes: response.attributes,
|
||||
childCategories: response.childCategories || [],
|
||||
loaded: true
|
||||
});
|
||||
}
|
||||
|
||||
fetchCategoryData(categoryId) {
|
||||
const cachedData = getCachedCategoryData(categoryId);
|
||||
if (cachedData) {
|
||||
this.processDataWithCategoryTree(cachedData, categoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch category data");
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit("getCategoryProducts", { categoryId: categoryId },
|
||||
(response) => {
|
||||
setCachedCategoryData(categoryId, response);
|
||||
if (response && response.products !== undefined) {
|
||||
this.processDataWithCategoryTree(response, categoryId);
|
||||
} else {
|
||||
console.log("fetchCategoryData in Content failed", response);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
processDataWithCategoryTree(response, categoryId) {
|
||||
// Get child categories from the cached category tree
|
||||
let childCategories = [];
|
||||
try {
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (categoryTreeCache && categoryTreeCache.categoryTree) {
|
||||
// If categoryId is a string (SEO name), find by seoName, otherwise by ID
|
||||
const targetCategory = typeof categoryId === 'string'
|
||||
? this.findCategoryBySeoName(categoryTreeCache.categoryTree, categoryId)
|
||||
: this.findCategoryById(categoryTreeCache.categoryTree, categoryId);
|
||||
|
||||
if (targetCategory && targetCategory.children) {
|
||||
childCategories = targetCategory.children;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error getting child categories from tree:', err);
|
||||
}
|
||||
|
||||
// Add child categories to the response
|
||||
const enhancedResponse = {
|
||||
...response,
|
||||
childCategories
|
||||
};
|
||||
|
||||
this.processData(enhancedResponse);
|
||||
}
|
||||
|
||||
findCategoryById(category, targetId) {
|
||||
if (!category) return null;
|
||||
|
||||
if (category.id === targetId) {
|
||||
return category;
|
||||
}
|
||||
|
||||
if (category.children) {
|
||||
for (let child of category.children) {
|
||||
const found = this.findCategoryById(child, targetId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fetchSearchData(query) {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch search data");
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit("getSearchProducts", { query },
|
||||
(response) => {
|
||||
if (response && response.products) {
|
||||
this.processData(response);
|
||||
} else {
|
||||
console.log("fetchSearchData in Content failed", response);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
filterProducts() {
|
||||
this.setState({
|
||||
...getFilteredProducts(
|
||||
this.state.unfilteredProducts,
|
||||
this.state.attributes
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to find category by seoName
|
||||
findCategoryBySeoName = (categoryNode, seoName) => {
|
||||
if (!categoryNode) return null;
|
||||
|
||||
if (categoryNode.seoName === seoName) {
|
||||
return categoryNode;
|
||||
}
|
||||
|
||||
if (categoryNode.children) {
|
||||
for (const child of categoryNode.children) {
|
||||
const found = this.findCategoryBySeoName(child, seoName);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to get current category ID from seoName
|
||||
getCurrentCategoryId = () => {
|
||||
const seoName = this.props.params.categoryId;
|
||||
|
||||
// Get the category tree from cache
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the category by seoName
|
||||
const category = this.findCategoryBySeoName(categoryTreeCache.categoryTree, seoName);
|
||||
return category ? category.id : null;
|
||||
}
|
||||
|
||||
renderParentCategoryNavigation = () => {
|
||||
const currentCategoryId = this.getCurrentCategoryId();
|
||||
if (!currentCategoryId) return null;
|
||||
|
||||
// Get the category tree from cache
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the current category in the tree
|
||||
const currentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategoryId);
|
||||
if (!currentCategory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this category has a parent (not root category 209)
|
||||
if (!currentCategory.parentId || currentCategory.parentId === 209) {
|
||||
return null; // Don't show for top-level categories
|
||||
}
|
||||
|
||||
// Find the parent category
|
||||
const parentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategory.parentId);
|
||||
if (!parentCategory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create parent category object for CategoryBox
|
||||
const parentCategoryForDisplay = {
|
||||
id: parentCategory.id,
|
||||
seoName: parentCategory.seoName,
|
||||
name: parentCategory.name,
|
||||
image: parentCategory.image,
|
||||
isParentNav: true
|
||||
};
|
||||
|
||||
return parentCategoryForDisplay;
|
||||
}
|
||||
|
||||
render() {
|
||||
// Check if we should show category boxes instead of product list
|
||||
const showCategoryBoxes = this.state.loaded &&
|
||||
this.state.unfilteredProducts.length === 0 &&
|
||||
this.state.childCategories.length > 0;
|
||||
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 2, flexGrow: 1, display: 'grid', gridTemplateRows: '1fr' }}>
|
||||
|
||||
{showCategoryBoxes ? (
|
||||
// Show category boxes layout when no products but have child categories
|
||||
<CategoryBoxGrid
|
||||
categories={this.state.childCategories}
|
||||
title={this.state.categoryName}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Show subcategories above main layout when there are both products and child categories */}
|
||||
{this.state.loaded &&
|
||||
this.state.unfilteredProducts.length > 0 &&
|
||||
this.state.childCategories.length > 0 && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
{(() => {
|
||||
const parentCategory = this.renderParentCategoryNavigation();
|
||||
if (parentCategory) {
|
||||
// Show parent category to the left of subcategories
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 3, flexWrap: 'wrap' }}>
|
||||
{/* Parent Category Box */}
|
||||
<Box sx={{ mt:2,position: 'relative', flexShrink: 0 }}>
|
||||
<CategoryBox
|
||||
id={parentCategory.id}
|
||||
seoName={parentCategory.seoName}
|
||||
name={parentCategory.name}
|
||||
image={parentCategory.image}
|
||||
height={130}
|
||||
fontSize="1.0rem"
|
||||
/>
|
||||
{/* Up Arrow Overlay */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
bgcolor: 'rgba(27, 94, 32, 0.8)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 100,
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Subcategories Grid */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<CategoryBoxGrid
|
||||
categories={this.state.childCategories}
|
||||
showTitle={false}
|
||||
spacing={3}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
// Just show subcategories without parent
|
||||
return (
|
||||
<CategoryBoxGrid
|
||||
categories={this.state.childCategories}
|
||||
showTitle={false}
|
||||
spacing={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Show parent category navigation when in 2nd or 3rd level but no subcategories */}
|
||||
{this.state.loaded &&
|
||||
this.props.params.categoryId &&
|
||||
!(this.state.unfilteredProducts.length > 0 && this.state.childCategories.length > 0) && (() => {
|
||||
const parentCategory = this.renderParentCategoryNavigation();
|
||||
if (parentCategory) {
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ position: 'relative', width: 'fit-content' }}>
|
||||
<CategoryBox
|
||||
id={parentCategory.id}
|
||||
seoName={parentCategory.seoName}
|
||||
name={parentCategory.name}
|
||||
image={parentCategory.image}
|
||||
height={130}
|
||||
fontSize="1.0rem"
|
||||
/>
|
||||
{/* Up Arrow Overlay */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
bgcolor: 'rgba(27, 94, 32, 0.8)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 100,
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Show normal product list layout */}
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 2fr', md: '1fr 3fr', lg: '1fr 4fr', xl: '1fr 4fr' },
|
||||
gap: 3
|
||||
}}>
|
||||
|
||||
<Stack direction="row" spacing={0} sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: { xs: 'min-content', sm: '100%' }
|
||||
}}>
|
||||
|
||||
<Box >
|
||||
|
||||
<ProductFilters
|
||||
products={this.state.unfilteredProducts}
|
||||
filteredProducts={this.state.filteredProducts}
|
||||
attributes={this.state.attributes}
|
||||
searchParams={this.props.searchParams}
|
||||
onFilterChange={()=>{this.filterProducts()}}
|
||||
dataType={this.state.dataType}
|
||||
dataParam={this.state.dataParam}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{(this.getCurrentCategoryId() == 706 || this.getCurrentCategoryId() == 689) &&
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
|
||||
<Typography variant="h6" sx={{mt:3}}>
|
||||
Andere Kategorien
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
|
||||
{this.props.params.categoryId == 'Stecklinge' && <Paper
|
||||
component={Link}
|
||||
to="/Kategorie/Seeds"
|
||||
sx={{
|
||||
p:0,
|
||||
mt: 1,
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
height: 300,
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: 10,
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: 20
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Image Container - Place your seeds image here */}
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
bgcolor: '#e1f0d3',
|
||||
backgroundImage: 'url("/assets/images/seeds.jpg")',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* Overlay text - optional */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bgcolor: 'rgba(27, 94, 32, 0.8)',
|
||||
p: 2,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
|
||||
Seeds
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
}
|
||||
|
||||
{this.props.params.categoryId == 'Seeds' && <Paper
|
||||
component={Link}
|
||||
to="/Kategorie/Stecklinge"
|
||||
sx={{
|
||||
p: 0,
|
||||
mt: 1,
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
height: 300,
|
||||
boxShadow: 10,
|
||||
transition: 'all 0.3s ease',
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: 20
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Image Container - Place your cutlings image here */}
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
bgcolor: '#e8f5d6',
|
||||
backgroundImage: 'url("/assets/images/cutlings.jpg")',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* Overlay text - optional */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bgcolor: 'rgba(27, 94, 32, 0.8)',
|
||||
p: 2,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
|
||||
Stecklinge
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>}
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<ProductList
|
||||
socket={this.props.socket}
|
||||
totalProductCount={(this.state.unfilteredProducts || []).length}
|
||||
products={this.state.filteredProducts || []}
|
||||
activeAttributeFilters={this.state.activeAttributeFilters || []}
|
||||
activeManufacturerFilters={this.state.activeManufacturerFilters || []}
|
||||
onFilterChange={()=>{this.filterProducts()}}
|
||||
dataType={this.state.dataType}
|
||||
dataParam={this.state.dataParam}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Content);
|
||||
319
src/components/Filter.js
Normal file
@@ -0,0 +1,319 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
class Filter extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const options = this.initializeOptions(props);
|
||||
const counts = this.initializeCounts(props,options);
|
||||
this.state = {
|
||||
options,
|
||||
counts,
|
||||
isCollapsed: true // Start collapsed on xs screens
|
||||
};
|
||||
}
|
||||
|
||||
initializeCounts = (props,options) => {
|
||||
const counts = {};
|
||||
|
||||
if(props.filterType === 'availability'){
|
||||
const products = options[1] ? props.products : props.products;
|
||||
if(products) for(const product of products){
|
||||
if(product.available) counts[1] = (counts[1] || 0) + 1;
|
||||
if(isNew(product.neu)) counts[2] = (counts[2] || 0) + 1;
|
||||
if(!product.available && product.incoming) counts[3] = (counts[3] || 0) + 1;
|
||||
}
|
||||
}
|
||||
if(props.filterType === 'manufacturer'){
|
||||
const uniqueManufacturers = [...new Set(props.products.filter(product => product.manufacturerId).map(product => product.manufacturerId))];
|
||||
const filteredManufacturers = uniqueManufacturers.filter(manufacturerId => options[manufacturerId] === true);
|
||||
const products = filteredManufacturers.length > 0 ? props.products : props.filteredProducts;
|
||||
for(const product of products){
|
||||
counts[product.manufacturerId] = (counts[product.manufacturerId] || 0) + 1;
|
||||
}
|
||||
}
|
||||
if(props.filterType === 'attribute'){
|
||||
//console.log('countCaclulation for attribute filter',props.title,this.props.title);
|
||||
const optionIds = props.options.map(option => option.id);
|
||||
//console.log('optionIds',optionIds);
|
||||
const attributeCount = {};
|
||||
for(const attribute of props.attributes){
|
||||
attributeCount[attribute.kMerkmalWert] = (attributeCount[attribute.kMerkmalWert] || 0) + 1;
|
||||
}
|
||||
const uniqueProductIds = props.filteredProducts.map(product => product.id);
|
||||
const attributesFilteredByUniqueAttributeProducts = props.attributes.filter(attribute => uniqueProductIds.includes(attribute.kArtikel));
|
||||
const attributeCountFiltered = {};
|
||||
for(const attribute of attributesFilteredByUniqueAttributeProducts){
|
||||
attributeCountFiltered[attribute.kMerkmalWert] = (attributeCountFiltered[attribute.kMerkmalWert] || 0) + 1;
|
||||
}
|
||||
let oneIsSelected = false;
|
||||
for(const option of optionIds) if(options[option]) oneIsSelected = true;
|
||||
for(const option of props.options){
|
||||
counts[option.id] = oneIsSelected?attributeCount[option.id]:attributeCountFiltered[option.id];
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
initializeOptions = (props) => {
|
||||
|
||||
if(props.filterType === 'attribute'){
|
||||
const attributeFilters = [];
|
||||
const attributeSettings = getAllSettingsWithPrefix('filter_attribute_');
|
||||
|
||||
Object.keys(attributeSettings).forEach(key => {
|
||||
if (attributeSettings[key] === 'true') {
|
||||
attributeFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
return attributeFilters.reduce((acc, filter) => {
|
||||
acc[filter] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
if(props.filterType === 'manufacturer'){
|
||||
const manufacturerFilters = [];
|
||||
const manufacturerSettings = getAllSettingsWithPrefix('filter_manufacturer_');
|
||||
|
||||
Object.keys(manufacturerSettings).forEach(key => {
|
||||
if (manufacturerSettings[key] === 'true') {
|
||||
manufacturerFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
return manufacturerFilters.reduce((acc, filter) => {
|
||||
acc[filter] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
if(props.filterType === 'availability'){
|
||||
const availabilityFilter = sessionStorage.getItem('filter_availability');
|
||||
const newFilters = [];
|
||||
const soonFilters = [];
|
||||
const availabilitySettings = getAllSettingsWithPrefix('filter_availability_');
|
||||
|
||||
Object.keys(availabilitySettings).forEach(key => {
|
||||
if (availabilitySettings[key] === 'true') {
|
||||
if(key.split('_')[2] == '2') newFilters.push(key.split('_')[2]);
|
||||
if(key.split('_')[2] == '3') soonFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
//console.log('newFilters',newFilters);
|
||||
const optionsState = {};
|
||||
if(!availabilityFilter) optionsState['1'] = true;
|
||||
if(newFilters.length > 0) optionsState['2'] = true;
|
||||
if(soonFilters.length > 0) optionsState['3'] = true;
|
||||
|
||||
const inStock = props.searchParams?.get('inStock');
|
||||
if(inStock) optionsState[inStock] = true;
|
||||
return optionsState;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// make this more fine grained with dependencies on props
|
||||
|
||||
if((prevProps.products !== this.props.products) || (prevProps.filteredProducts !== this.props.filteredProducts) || (prevProps.options !== this.props.options) || (prevProps.attributes !== this.props.attributes)){
|
||||
const options = this.initializeOptions(this.props);
|
||||
const counts = this.initializeCounts(this.props,options);
|
||||
this.setState({
|
||||
options,
|
||||
counts
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleOptionChange = (event) => {
|
||||
const { name, checked } = event.target;
|
||||
|
||||
// Update local state first to ensure immediate UI feedback
|
||||
this.setState(prevState => ({
|
||||
options: {
|
||||
...prevState.options,
|
||||
[name]: checked
|
||||
}
|
||||
}));
|
||||
|
||||
// Then notify the parent component
|
||||
if (this.props.onFilterChange) {
|
||||
this.props.onFilterChange({
|
||||
type: this.props.filterType || 'default',
|
||||
name: name,
|
||||
value: checked
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
resetFilters = () => {
|
||||
// Reset current filter's state
|
||||
const emptyOptions = {};
|
||||
Object.keys(this.state.options).forEach(option => {
|
||||
emptyOptions[option] = false;
|
||||
});
|
||||
|
||||
this.setState({ options: emptyOptions });
|
||||
|
||||
// Notify parent component to reset ALL filters (including other filter components)
|
||||
if (this.props.onFilterChange) {
|
||||
this.props.onFilterChange({
|
||||
type: 'RESET_ALL_FILTERS',
|
||||
resetAll: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
toggleCollapse = () => {
|
||||
this.setState(prevState => ({
|
||||
isCollapsed: !prevState.isCollapsed
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { options, counts, isCollapsed } = this.state;
|
||||
const { title, options: optionsList = [] } = this.props;
|
||||
|
||||
// Check if we're on xs screen size
|
||||
const isXsScreen = window.innerWidth < 600;
|
||||
|
||||
const tableStyle = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse'
|
||||
};
|
||||
|
||||
const cellStyle = {
|
||||
padding: '0px 0',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: '1'
|
||||
};
|
||||
|
||||
const checkboxCellStyle = {
|
||||
...cellStyle,
|
||||
width: '20px',
|
||||
verticalAlign: 'middle',
|
||||
paddingRight: '8px'
|
||||
};
|
||||
|
||||
const labelCellStyle = {
|
||||
...cellStyle,
|
||||
cursor: 'pointer',
|
||||
verticalAlign: 'middle',
|
||||
userSelect: 'none'
|
||||
};
|
||||
|
||||
const countCellStyle = {
|
||||
...cellStyle,
|
||||
textAlign: 'right',
|
||||
color: 'rgba(0, 0, 0, 0.6)',
|
||||
fontSize: '1rem',
|
||||
verticalAlign: 'middle'
|
||||
};
|
||||
|
||||
const countBoxStyle = {
|
||||
display: 'inline-block',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.7rem',
|
||||
minWidth: '16px',
|
||||
textAlign: 'center',
|
||||
color: 'rgba(0, 0, 0, 0.7)'
|
||||
};
|
||||
|
||||
const resetButtonStyle = {
|
||||
padding: '2px 8px',
|
||||
fontSize: '0.7rem',
|
||||
backgroundColor: '#f0f0f0',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
color: 'rgba(0, 0, 0, 0.7)',
|
||||
float: 'right'
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: { xs: 'pointer', sm: 'default' }
|
||||
}}
|
||||
onClick={isXsScreen ? this.toggleCollapse : undefined}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="medium" gutterBottom={!isXsScreen}>
|
||||
{title}
|
||||
{/* Only show reset button on Availability filter */}
|
||||
{title === "VerfügbarkeitDISABLED" && (
|
||||
<button
|
||||
style={resetButtonStyle}
|
||||
onClick={this.resetFilters}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</Typography>
|
||||
{isXsScreen && (
|
||||
<IconButton size="small" sx={{ p: 0 }}>
|
||||
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Collapse in={!isXsScreen || !isCollapsed}>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<table style={tableStyle}>
|
||||
<tbody>
|
||||
{optionsList.map((option) => (
|
||||
<tr key={option.id} style={{ height: '32px' }}>
|
||||
<td style={checkboxCellStyle}>
|
||||
<Checkbox
|
||||
checked={options[option.id] || false}
|
||||
onChange={this.handleOptionChange}
|
||||
name={option.id}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{
|
||||
padding: '0px',
|
||||
'& .MuiSvgIcon-root': { fontSize: 28 }
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td style={labelCellStyle} onClick={() => {
|
||||
const event = { target: { name: option.id, checked: !options[option.id] } };
|
||||
this.handleOptionChange(event);
|
||||
}}>
|
||||
{option.name}
|
||||
</td>
|
||||
<td style={countCellStyle}>
|
||||
{counts && counts[option.id] !== undefined && (
|
||||
<span style={countBoxStyle}>
|
||||
{counts[option.id]}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Filter;
|
||||
354
src/components/Footer.js
Normal file
@@ -0,0 +1,354 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
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';
|
||||
|
||||
// Styled component for the router links
|
||||
const StyledRouterLink = styled(RouterLink)(() => ({
|
||||
color: 'inherit',
|
||||
fontSize: '13px',
|
||||
textDecoration: 'none',
|
||||
lineHeight: '1.5',
|
||||
display: 'block',
|
||||
padding: '4px 8px',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}));
|
||||
|
||||
// Styled component for the domain link
|
||||
const StyledDomainLink = styled(Link)(() => ({
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
lineHeight: '1.5',
|
||||
'&:hover': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
}));
|
||||
|
||||
// Styled component for the dark overlay
|
||||
const DarkOverlay = styled(Box)(() => ({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
zIndex: 9998,
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 0.9s ease',
|
||||
}));
|
||||
|
||||
// Styled component for the info bubble
|
||||
const InfoBubble = styled(Paper)(({ theme }) => ({
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
padding: theme.spacing(3),
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
minWidth: '280px',
|
||||
maxWidth: '400px',
|
||||
textAlign: 'center',
|
||||
transition: 'all 0.9s ease',
|
||||
}));
|
||||
|
||||
class Footer extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showMapsInfo: false,
|
||||
showReviewsInfo: false,
|
||||
};
|
||||
}
|
||||
|
||||
handleMapsMouseEnter = () => {
|
||||
this.setState({ showMapsInfo: true });
|
||||
};
|
||||
|
||||
handleMapsMouseLeave = () => {
|
||||
this.setState({ showMapsInfo: false });
|
||||
};
|
||||
|
||||
handleReviewsMouseEnter = () => {
|
||||
this.setState({ showReviewsInfo: true });
|
||||
};
|
||||
|
||||
handleReviewsMouseLeave = () => {
|
||||
this.setState({ showReviewsInfo: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { showMapsInfo, showReviewsInfo } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dark overlay for Maps */}
|
||||
<DarkOverlay sx={{
|
||||
opacity: showMapsInfo ? 1 : 0
|
||||
}} />
|
||||
|
||||
{/* Dark overlay for Reviews */}
|
||||
<DarkOverlay sx={{
|
||||
opacity: showReviewsInfo ? 1 : 0
|
||||
}} />
|
||||
|
||||
{/* Info bubble */}
|
||||
<InfoBubble
|
||||
elevation={8}
|
||||
sx={{
|
||||
opacity: showMapsInfo ? 1 : 0,
|
||||
visibility: showMapsInfo ? 'visible' : 'hidden',
|
||||
transform: showMapsInfo ? 'translate(-50%, -50%) scale(1)' : 'translate(-50%, -50%) scale(0.8)'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
color: 'primary.main',
|
||||
fontSize: '1.25rem'
|
||||
}}
|
||||
>
|
||||
Filiale
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 1,
|
||||
color: 'text.primary'
|
||||
}}
|
||||
>
|
||||
Öffnungszeiten:
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Mo-Fr 10-20
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Sa 11-19
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 1,
|
||||
color: 'text.primary'
|
||||
}}
|
||||
>
|
||||
Trachenberger Straße 14 - Dresden
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontStyle: 'italic',
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Zwischen Haltepunkt Pieschen und Trachenberger Platz
|
||||
</Typography>
|
||||
</InfoBubble>
|
||||
|
||||
{/* Reviews Info bubble */}
|
||||
<InfoBubble
|
||||
elevation={8}
|
||||
sx={{
|
||||
opacity: showReviewsInfo ? 1 : 0,
|
||||
visibility: showReviewsInfo ? 'visible' : 'hidden',
|
||||
transform: showReviewsInfo ? 'translate(-50%, -50%) scale(1)' : 'translate(-50%, -50%) scale(0.8)',
|
||||
width: 'auto',
|
||||
minWidth: 'auto',
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '90vh',
|
||||
padding: 2
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/reviews.jpg"
|
||||
alt="Customer Reviews"
|
||||
sx={{
|
||||
width: '861px',
|
||||
height: '371px',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '80vh',
|
||||
borderRadius: '8px',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
</InfoBubble>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
py: 2,
|
||||
px: 2,
|
||||
mt: 'auto',
|
||||
mb: 0,
|
||||
backgroundColor: 'primary.dark',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
sx={{ filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))',maxWidth: 'md', margin: 'auto' }}
|
||||
spacing={{ xs: 3, md: 2 }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'center', md: 'flex-end' }}
|
||||
>
|
||||
{/* Legal Links Section */}
|
||||
<Stack
|
||||
direction={{ xs: 'row', md: 'column' }}
|
||||
spacing={{ xs: 2, md: 0.5 }}
|
||||
justifyContent="center"
|
||||
alignItems={{ xs: 'center', md: 'left' }}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<StyledRouterLink to="/datenschutz">Datenschutz</StyledRouterLink>
|
||||
<StyledRouterLink to="/agb">AGB</StyledRouterLink>
|
||||
<StyledRouterLink to="/sitemap">Sitemap</StyledRouterLink>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'row', md: 'column' }}
|
||||
spacing={{ xs: 2, md: 0.5 }}
|
||||
justifyContent="center"
|
||||
alignItems={{ xs: 'center', md: 'left' }}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<StyledRouterLink to="/impressum">Impressum</StyledRouterLink>
|
||||
<StyledRouterLink to="/batteriegesetzhinweise">Batteriegesetzhinweise</StyledRouterLink>
|
||||
<StyledRouterLink to="/widerrufsrecht">Widerrufsrecht</StyledRouterLink>
|
||||
</Stack>
|
||||
|
||||
{/* Payment Methods Section */}
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={1}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={{ xs: 1, md: 2 }}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<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"
|
||||
spacing={1}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={{ xs: 1, md: 2 }}
|
||||
sx={{pb: '10px'}}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Link
|
||||
href="https://reviewthis.biz/growheads"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
zIndex: 9999
|
||||
}}
|
||||
onMouseEnter={this.handleReviewsMouseEnter}
|
||||
onMouseLeave={this.handleReviewsMouseLeave}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/gg.png"
|
||||
alt="Google Reviews"
|
||||
sx={{
|
||||
height: { xs: 50, md: 60 },
|
||||
cursor: 'pointer',
|
||||
transition: 'all 2s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.5) translateY(-10px)'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://maps.app.goo.gl/D67ewDU3dZBda1BUA"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
zIndex: 9999
|
||||
}}
|
||||
onMouseEnter={this.handleMapsMouseEnter}
|
||||
onMouseLeave={this.handleMapsMouseLeave}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/maps.png"
|
||||
alt="Google Maps"
|
||||
sx={{
|
||||
height: { xs: 40, md: 50 },
|
||||
cursor: 'pointer',
|
||||
transition: 'all 2s ease',
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.5) translateY(-10px)',
|
||||
filter: 'drop-shadow(0 8px 16px rgba(0, 0, 0, 0.4))'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Copyright Section */}
|
||||
<Box sx={{ pb:'20px',textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
||||
* Alle Preise inkl. gesetzlicher USt., zzgl. Versand
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
||||
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
208
src/components/GoogleLoginButton.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { Component } from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import GoogleAuthContext from '../contexts/GoogleAuthContext.js';
|
||||
|
||||
class GoogleLoginButton extends Component {
|
||||
static contextType = GoogleAuthContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isInitialized: false,
|
||||
isInitializing: false,
|
||||
promptShown: false,
|
||||
isPrompting: false // @note Added to prevent multiple simultaneous prompts
|
||||
};
|
||||
this.promptTimeout = null; // @note Added to track timeout
|
||||
this.prevContextLoaded = false; // @note Track previous context loaded state
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Check if Google libraries are already available
|
||||
const hasGoogleLoaded = window.google && window.google.accounts && window.google.accounts.id;
|
||||
const contextLoaded = this.context && this.context.isLoaded;
|
||||
|
||||
// @note Initialize the tracked context loaded state
|
||||
this.prevContextLoaded = contextLoaded;
|
||||
|
||||
// @note Only initialize immediately if context is already loaded, otherwise let componentDidUpdate handle it
|
||||
if (hasGoogleLoaded && this.context.clientId && contextLoaded) {
|
||||
this.initializeGoogleSignIn();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
// Initialize when all conditions are met and we haven't initialized before
|
||||
const hasGoogleLoaded = window.google && window.google.accounts && window.google.accounts.id;
|
||||
const contextLoaded = this.context && this.context.isLoaded;
|
||||
|
||||
// @note Only initialize when context becomes loaded for the first time
|
||||
if (!this.state.isInitialized &&
|
||||
!this.state.isInitializing &&
|
||||
hasGoogleLoaded &&
|
||||
this.context.clientId &&
|
||||
contextLoaded &&
|
||||
!this.prevContextLoaded) {
|
||||
this.initializeGoogleSignIn();
|
||||
}
|
||||
|
||||
// @note Update the tracked context loaded state
|
||||
this.prevContextLoaded = contextLoaded;
|
||||
|
||||
// Auto-prompt if initialization is complete and autoInitiate is true
|
||||
if (this.props.autoInitiate &&
|
||||
this.state.isInitialized &&
|
||||
!this.state.promptShown &&
|
||||
!this.state.isPrompting && // @note Added check to prevent multiple prompts
|
||||
(!prevState.isInitialized || !prevProps.autoInitiate)) {
|
||||
this.setState({ promptShown: true });
|
||||
this.schedulePrompt(100);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// @note Clear timeout on unmount to prevent memory leaks
|
||||
if (this.promptTimeout) {
|
||||
clearTimeout(this.promptTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
schedulePrompt = (delay = 0) => {
|
||||
// @note Clear any existing timeout
|
||||
if (this.promptTimeout) {
|
||||
clearTimeout(this.promptTimeout);
|
||||
}
|
||||
|
||||
this.promptTimeout = setTimeout(() => {
|
||||
this.tryPrompt();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
initializeGoogleSignIn = () => {
|
||||
// Avoid multiple initialization attempts
|
||||
if (this.state.isInitialized || this.state.isInitializing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isInitializing: true });
|
||||
|
||||
if (!window.google || !window.google.accounts || !window.google.accounts.id) {
|
||||
console.error('Google Sign-In API not loaded yet');
|
||||
this.setState({ isInitializing: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: this.context.clientId,
|
||||
callback: this.handleCredentialResponse,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
isInitialized: true,
|
||||
isInitializing: false
|
||||
}, () => {
|
||||
// Auto-prompt immediately if autoInitiate is true
|
||||
if (this.props.autoInitiate && !this.state.promptShown && !this.state.isPrompting) {
|
||||
this.setState({ promptShown: true });
|
||||
this.schedulePrompt(100);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Google Sign-In initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Google Sign-In:', error);
|
||||
this.setState({
|
||||
isInitializing: false
|
||||
});
|
||||
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleCredentialResponse = (response) => {
|
||||
console.log('cred',response);
|
||||
const { onSuccess, onError } = this.props;
|
||||
|
||||
// @note Reset prompting state when response is received
|
||||
this.setState({ isPrompting: false });
|
||||
|
||||
if (response && response.credential) {
|
||||
// Call onSuccess with the credential
|
||||
if (onSuccess) {
|
||||
onSuccess(response);
|
||||
}
|
||||
} else {
|
||||
// Call onError if something went wrong
|
||||
if (onError) {
|
||||
onError(new Error('Failed to get credential from Google'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
// @note Prevent multiple clicks while prompting
|
||||
if (this.state.isPrompting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If not initialized yet, try initializing first
|
||||
if (!this.state.isInitialized && !this.state.isInitializing) {
|
||||
this.initializeGoogleSignIn();
|
||||
// Add a small delay before attempting to prompt
|
||||
this.schedulePrompt(300);
|
||||
return;
|
||||
}
|
||||
|
||||
this.tryPrompt();
|
||||
};
|
||||
|
||||
tryPrompt = () => {
|
||||
// @note Prevent multiple simultaneous prompts
|
||||
if (this.state.isPrompting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.google || !window.google.accounts || !window.google.accounts.id) {
|
||||
console.error('Google Sign-In API not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.setState({ isPrompting: true });
|
||||
window.google.accounts.id.prompt();
|
||||
this.setState({ promptShown: true });
|
||||
console.log('Google Sign-In prompt displayed');
|
||||
} catch (error) {
|
||||
console.error('Error prompting Google Sign-In:', error);
|
||||
this.setState({ isPrompting: false });
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
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}
|
||||
style={{ backgroundColor: '#4285F4', color: 'white', ...style }}
|
||||
className={className}
|
||||
>
|
||||
{isLoading ? 'Loading...' : text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default GoogleLoginButton;
|
||||
99
src/components/Header.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { Component } from 'react';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
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
|
||||
import { Logo, SearchBar, ButtonGroupWithRouter, CategoryList } from './header/index.js';
|
||||
|
||||
// Main Header Component
|
||||
class Header extends Component {
|
||||
static contextType = SocketContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
cartItems: []
|
||||
};
|
||||
}
|
||||
|
||||
handleCartQuantityChange = (productId, quantity) => {
|
||||
this.setState(prevState => ({
|
||||
cartItems: prevState.cartItems.map(item =>
|
||||
item.id === productId ? { ...item, quantity } : item
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
handleCartRemoveItem = (productId) => {
|
||||
this.setState(prevState => ({
|
||||
cartItems: prevState.cartItems.filter(item => item.id !== productId)
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
// Get socket directly from context in render method
|
||||
const socket = this.context;
|
||||
const { isHomePage, isProfilePage } = this.props;
|
||||
|
||||
return (
|
||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
|
||||
<Toolbar sx={{ minHeight: 64 }}>
|
||||
<Container maxWidth="lg" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{/* First row: Logo and ButtonGroup on xs, all items on larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexDirection: { xs: 'column', sm: 'row' }
|
||||
}}>
|
||||
{/* Top row for xs, single row for larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: { xs: 'space-between', sm: 'flex-start' }
|
||||
}}>
|
||||
<Logo />
|
||||
{/* SearchBar visible on sm and up */}
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' }, flexGrow: 1 }}>
|
||||
<SearchBar />
|
||||
</Box>
|
||||
<ButtonGroupWithRouter socket={socket}/>
|
||||
</Box>
|
||||
|
||||
{/* Second row: SearchBar only on xs */}
|
||||
<Box sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
width: '100%',
|
||||
mt: 1,mb: 1
|
||||
}}>
|
||||
<SearchBar />
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Toolbar>
|
||||
{(isHomePage || this.props.categoryId || isProfilePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId} socket={socket} />}
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Use a wrapper function to provide context
|
||||
const HeaderWithContext = (props) => {
|
||||
const location = useLocation();
|
||||
const isHomePage = location.pathname === '/';
|
||||
const isProfilePage = location.pathname === '/profile';
|
||||
|
||||
return (
|
||||
<SocketContext.Consumer>
|
||||
{socket => <Header {...props} socket={socket} isHomePage={isHomePage} isProfilePage={isProfilePage} />}
|
||||
</SocketContext.Consumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderWithContext;
|
||||
325
src/components/Images.js
Normal file
@@ -0,0 +1,325 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import LoupeIcon from '@mui/icons-material/Loupe';
|
||||
|
||||
class Images extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { mainPic:0,pics:[]};
|
||||
|
||||
console.log('Images constructor',props);
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.updatePics(0);
|
||||
}
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.fullscreenOpen !== this.props.fullscreenOpen) {
|
||||
this.updatePics();
|
||||
}
|
||||
}
|
||||
|
||||
updatePics = (newMainPic = this.state.mainPic) => {
|
||||
if (!window.tinyPicCache) window.tinyPicCache = {};
|
||||
if (!window.smallPicCache) window.smallPicCache = {};
|
||||
if (!window.mediumPicCache) window.mediumPicCache = {};
|
||||
if (!window.largePicCache) window.largePicCache = {};
|
||||
|
||||
if(this.props.pictureList && this.props.pictureList.length > 0){
|
||||
const bildIds = this.props.pictureList.split(',');
|
||||
|
||||
|
||||
const pics = [];
|
||||
const mainPicId = bildIds[newMainPic];
|
||||
|
||||
for(const bildId of bildIds){
|
||||
if(bildId == mainPicId){
|
||||
|
||||
if(window.largePicCache[bildId]){
|
||||
pics.push(window.largePicCache[bildId]);
|
||||
}else if(window.mediumPicCache[bildId]){
|
||||
pics.push(window.mediumPicCache[bildId]);
|
||||
if(this.props.fullscreenOpen) this.loadPic('large',bildId,newMainPic);
|
||||
}else if(window.smallPicCache[bildId]){
|
||||
pics.push(window.smallPicCache[bildId]);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}else if(window.tinyPicCache[bildId]){
|
||||
pics.push(bildId);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}else{
|
||||
pics.push(bildId);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}
|
||||
}else{
|
||||
if(window.tinyPicCache[bildId]){
|
||||
pics.push(window.tinyPicCache[bildId]);
|
||||
}else if(window.mediumPicCache[bildId]){
|
||||
pics.push(window.mediumPicCache[bildId]);
|
||||
this.loadPic('tiny',bildId,newMainPic);
|
||||
}else{
|
||||
pics.push(null);
|
||||
this.loadPic('tiny',bildId,pics.length-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('pics',pics);
|
||||
this.setState({ pics, mainPic: newMainPic });
|
||||
}else{
|
||||
if(this.state.pics.length > 0) this.setState({ pics:[], mainPic: newMainPic });
|
||||
}
|
||||
}
|
||||
|
||||
loadPic = (size,bildId,index) => {
|
||||
this.props.socket.emit('getPic', { bildId, size }, (res) => {
|
||||
if(res.success){
|
||||
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;
|
||||
if(size === 'tiny') window.tinyPicCache[bildId] = url;
|
||||
if(size === 'large') window.largePicCache[bildId] = url;
|
||||
const pics = this.state.pics;
|
||||
pics[index] = url
|
||||
this.setState({ pics });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleThumbnailClick = (clickedPic) => {
|
||||
// Find the original index of the clicked picture in the full pics array
|
||||
const originalIndex = this.state.pics.findIndex(pic => pic === clickedPic);
|
||||
if (originalIndex !== -1) {
|
||||
this.updatePics(originalIndex);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
{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
|
||||
const originalIndex = this.state.pics.findIndex(p => p === pic);
|
||||
return (
|
||||
<Box key={filterIndex} sx={{ position: 'relative' }}>
|
||||
<Badge
|
||||
badgeContent={originalIndex + 1}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
backgroundColor: 'rgba(119, 155, 191, 0.79)',
|
||||
color: 'white',
|
||||
fontSize: '0.7rem',
|
||||
minWidth: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
top: 4,
|
||||
right: 4,
|
||||
border: '2px solid rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: 'bold',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out'
|
||||
},
|
||||
'&:hover .MuiBadge-badge': {
|
||||
opacity: 1
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="80"
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
border: '2px solid transparent',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
border: '2px solid #1976d2',
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.2)'
|
||||
}
|
||||
}}
|
||||
image={pic}
|
||||
onClick={() => this.handleThumbnailClick(pic)}
|
||||
/>
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{/* Fullscreen Dialog */}
|
||||
<Dialog
|
||||
open={this.props.fullscreenOpen || false}
|
||||
onClose={this.props.onCloseFullscreen}
|
||||
maxWidth={false}
|
||||
fullScreen
|
||||
sx={{
|
||||
'& .MuiDialog-paper': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
sx={{
|
||||
p: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
height: '100vh',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Only close if clicking on the background (DialogContent itself)
|
||||
if (e.target === e.currentTarget) {
|
||||
this.props.onCloseFullscreen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<IconButton
|
||||
onClick={this.props.onCloseFullscreen}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
|
||||
{/* Main Image in Fullscreen */}
|
||||
{this.state.pics[this.state.mainPic] && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
width: '90vw',
|
||||
height: '80vh'
|
||||
}}
|
||||
image={this.state.pics[this.state.mainPic]}
|
||||
onClick={this.props.onCloseFullscreen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Stack in Fullscreen */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: '90%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Stack direction="row" spacing={2} sx={{ justifyContent: 'center', p: 3 }}>
|
||||
{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
|
||||
const originalIndex = this.state.pics.findIndex(p => p === pic);
|
||||
return (
|
||||
<Box key={filterIndex} sx={{ position: 'relative' }}>
|
||||
<Badge
|
||||
badgeContent={originalIndex + 1}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
backgroundColor: 'rgba(119, 155, 191, 0.79)',
|
||||
color: 'white',
|
||||
fontSize: '0.7rem',
|
||||
minWidth: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
top: 4,
|
||||
right: 4,
|
||||
border: '2px solid rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: 'bold',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out'
|
||||
},
|
||||
'&:hover .MuiBadge-badge': {
|
||||
opacity: 1
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="60"
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
border: '2px solid #1976d2',
|
||||
transform: 'scale(1.1)',
|
||||
boxShadow: '0 4px 8px rgba(25, 118, 210, 0.5)'
|
||||
}
|
||||
}}
|
||||
image={pic}
|
||||
onClick={() => this.handleThumbnailClick(pic)}
|
||||
/>
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Images;
|
||||
743
src/components/LoginComponent.js
Normal file
@@ -0,0 +1,743 @@
|
||||
import React, { lazy, Component, Suspense } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import { withRouter } from './withRouter.js';
|
||||
import GoogleLoginButton from './GoogleLoginButton.js';
|
||||
import CartSyncDialog from './CartSyncDialog.js';
|
||||
import { localAndArchiveServer, mergeCarts } from '../utils/cartUtils.js';
|
||||
import config from '../config.js';
|
||||
|
||||
// Lazy load GoogleAuthProvider
|
||||
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
|
||||
|
||||
// Function to check if user is logged in
|
||||
export const isUserLoggedIn = () => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(storedUser);
|
||||
console.log('Parsed User:', parsedUser);
|
||||
return { isLoggedIn: true, user: parsedUser, isAdmin: !!parsedUser.admin };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error parsing user from sessionStorage:', error);
|
||||
sessionStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
console.log('isUserLoggedIn', false);
|
||||
return { isLoggedIn: false, user: null, isAdmin: false };
|
||||
};
|
||||
|
||||
// Hilfsfunktion zum Vergleich zweier Cart-Arrays
|
||||
function cartsAreIdentical(cartA, cartB) {
|
||||
console.log('Vergleiche Carts:', {cartA, cartB});
|
||||
if (!Array.isArray(cartA) || !Array.isArray(cartB)) {
|
||||
console.log('Mindestens eines der Carts ist kein Array');
|
||||
return false;
|
||||
}
|
||||
if (cartA.length !== cartB.length) {
|
||||
console.log('Unterschiedliche Längen:', cartA.length, cartB.length);
|
||||
return false;
|
||||
}
|
||||
const sortById = arr => [...arr].sort((a, b) => (a.id > b.id ? 1 : -1));
|
||||
const aSorted = sortById(cartA);
|
||||
const bSorted = sortById(cartB);
|
||||
for (let i = 0; i < aSorted.length; i++) {
|
||||
if (aSorted[i].id !== bSorted[i].id) {
|
||||
console.log('Unterschiedliche IDs:', aSorted[i].id, bSorted[i].id, aSorted[i], bSorted[i]);
|
||||
return false;
|
||||
}
|
||||
if (aSorted[i].quantity !== bSorted[i].quantity) {
|
||||
console.log('Unterschiedliche Mengen:', aSorted[i].id, aSorted[i].quantity, bSorted[i].quantity);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
console.log('Carts sind identisch');
|
||||
return true;
|
||||
}
|
||||
|
||||
export class LoginComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
open: false,
|
||||
tabValue: 0,
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
error: '',
|
||||
loading: false,
|
||||
success: '',
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
user: null,
|
||||
anchorEl: null,
|
||||
showGoogleAuth: false,
|
||||
cartSyncOpen: false,
|
||||
localCartSync: [],
|
||||
serverCartSync: [],
|
||||
pendingNavigate: null,
|
||||
privacyConfirmed: sessionStorage.getItem('privacyConfirmed') === 'true'
|
||||
};
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.open !== prevProps.open) {
|
||||
this.setState({ open: this.props.open });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup function to remove global reference when component unmounts
|
||||
window.openLoginDrawer = undefined;
|
||||
}
|
||||
|
||||
resetForm = () => {
|
||||
this.setState({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
error: '',
|
||||
success: '',
|
||||
loading: false,
|
||||
showGoogleAuth: false // Reset Google auth state when form is reset
|
||||
});
|
||||
};
|
||||
|
||||
handleOpen = () => {
|
||||
this.setState({
|
||||
open: true,
|
||||
loading: false,
|
||||
privacyConfirmed: sessionStorage.getItem('privacyConfirmed') === 'true'
|
||||
});
|
||||
this.resetForm();
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({
|
||||
open: false,
|
||||
showGoogleAuth: false // Reset Google auth state when dialog closes
|
||||
});
|
||||
this.resetForm();
|
||||
};
|
||||
|
||||
handleTabChange = (event, newValue) => {
|
||||
this.setState({
|
||||
tabValue: newValue,
|
||||
error: '',
|
||||
success: ''
|
||||
});
|
||||
};
|
||||
|
||||
validateEmail = (email) => {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
};
|
||||
|
||||
handleLogin = () => {
|
||||
const { email, password } = this.state;
|
||||
const { socket, location, navigate } = this.props;
|
||||
|
||||
if (!email || !password) {
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call verifyUser socket endpoint
|
||||
if (!socket || !socket.connected) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('verifyUser', { email, password }, (response) => {
|
||||
console.log('LoginComponent: verifyUser', response);
|
||||
if (response.success) {
|
||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||
this.setState({
|
||||
user: response.user,
|
||||
isLoggedIn: true,
|
||||
isAdmin: !!response.user.admin
|
||||
});
|
||||
|
||||
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
const dispatchLoginEvent = () => {
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
navigate(redirectTo);
|
||||
}
|
||||
|
||||
try {
|
||||
const newCart = JSON.parse(response.user.cart);
|
||||
const localCartArr = window.cart ? Object.values(window.cart) : [];
|
||||
const serverCartArr = newCart ? Object.values(newCart) : [];
|
||||
|
||||
if (serverCartArr.length === 0) {
|
||||
if (socket && socket.connected) {
|
||||
socket.emit('updateCart', window.cart);
|
||||
}
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
||||
window.cart = serverCartArr;
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (cartsAreIdentical(localCartArr, serverCartArr)) {
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else {
|
||||
this.setState({
|
||||
cartSyncOpen: true,
|
||||
localCartSync: localCartArr,
|
||||
serverCartSync: serverCartArr,
|
||||
pendingNavigate: dispatchLoginEvent
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing cart:', response.user, error);
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Anmeldung fehlgeschlagen'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleRegister = () => {
|
||||
const { email, password, confirmPassword } = this.state;
|
||||
const { socket } = this.props;
|
||||
|
||||
if (!email || !password || !confirmPassword) {
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
this.setState({ error: 'Passwörter stimmen nicht überein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
this.setState({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call createUser socket endpoint
|
||||
if (!socket || !socket.connected) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('createUser', { email, password }, (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
|
||||
tabValue: 0 // Switch to login tab
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Registrierung fehlgeschlagen'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleUserMenuClick = (event) => {
|
||||
this.setState({ anchorEl: event.currentTarget });
|
||||
};
|
||||
|
||||
handleUserMenuClose = () => {
|
||||
this.setState({ anchorEl: null });
|
||||
};
|
||||
|
||||
handleLogout = () => {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// If socket is not connected, just clear local storage
|
||||
sessionStorage.removeItem('user');
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
window.dispatchEvent(new CustomEvent('userLoggedOut'));
|
||||
this.setState({
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
isAdmin: false,
|
||||
anchorEl: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit('logout', (response) => {
|
||||
if(response.success){
|
||||
sessionStorage.removeItem('user');
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
this.props.navigate('/');
|
||||
this.setState({
|
||||
user: null,
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
anchorEl: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleForgotPassword = () => {
|
||||
const { email } = this.state;
|
||||
const { socket } = this.props;
|
||||
|
||||
if (!email) {
|
||||
this.setState({ error: 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call resetPassword socket endpoint
|
||||
socket.emit('resetPassword', {
|
||||
email,
|
||||
domain: window.location.origin
|
||||
}, (response) => {
|
||||
console.log('Reset Password Response:', response);
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Fehler beim Senden der E-Mail'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Google login functionality
|
||||
handleGoogleLoginSuccess = (credentialResponse) => {
|
||||
const { socket, location, navigate } = this.props;
|
||||
this.setState({ loading: true, error: '' });
|
||||
console.log('beforeG',credentialResponse)
|
||||
|
||||
|
||||
|
||||
socket.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
|
||||
console.log('google respo',response);
|
||||
if (response.success) {
|
||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||
this.setState({
|
||||
isLoggedIn: true,
|
||||
isAdmin: !!response.user.admin,
|
||||
user: response.user
|
||||
});
|
||||
|
||||
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
const dispatchLoginEvent = () => {
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
navigate(redirectTo);
|
||||
};
|
||||
|
||||
try {
|
||||
const newCart = JSON.parse(response.user.cart);
|
||||
const localCartArr = window.cart ? Object.values(window.cart) : [];
|
||||
const serverCartArr = newCart ? Object.values(newCart) : [];
|
||||
|
||||
if (serverCartArr.length === 0) {
|
||||
socket.emit('updateCart', window.cart);
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
||||
window.cart = serverCartArr;
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (cartsAreIdentical(localCartArr, serverCartArr)) {
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else {
|
||||
this.setState({
|
||||
cartSyncOpen: true,
|
||||
localCartSync: localCartArr,
|
||||
serverCartSync: serverCartArr,
|
||||
pendingNavigate: dispatchLoginEvent
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing cart:', response.user, error);
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false // Reset Google auth state on failed login
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleGoogleLoginError = (error) => {
|
||||
console.error('Google Login Error:', error);
|
||||
this.setState({
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false, // Reset Google auth state on error
|
||||
loading: false
|
||||
});
|
||||
};
|
||||
|
||||
handleCartSyncConfirm = async (option) => {
|
||||
const { localCartSync, serverCartSync, pendingNavigate } = this.state;
|
||||
switch (option) {
|
||||
case 'useLocalArchive':
|
||||
localAndArchiveServer(localCartSync, serverCartSync);
|
||||
break;
|
||||
case 'deleteServer':
|
||||
this.props.socket.emit('updateCart', window.cart)
|
||||
break;
|
||||
case 'useServer':
|
||||
window.cart = serverCartSync;
|
||||
break;
|
||||
case 'merge':
|
||||
default: {
|
||||
const merged = mergeCarts(localCartSync, serverCartSync);
|
||||
console.log('MERGED CART RESULT:', merged);
|
||||
window.cart = merged;
|
||||
break;
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
this.setState({ cartSyncOpen: false, localCartSync: [], serverCartSync: [], pendingNavigate: null });
|
||||
this.handleClose();
|
||||
if (pendingNavigate) pendingNavigate();
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
open,
|
||||
tabValue,
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
error,
|
||||
loading,
|
||||
success,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
anchorEl,
|
||||
showGoogleAuth,
|
||||
cartSyncOpen,
|
||||
localCartSync,
|
||||
serverCartSync,
|
||||
privacyConfirmed
|
||||
} = this.state;
|
||||
|
||||
const { open: openProp, handleClose: handleCloseProp } = this.props;
|
||||
const isExternallyControlled = openProp !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isExternallyControlled && (
|
||||
isLoggedIn ? (
|
||||
<>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={this.handleUserMenuClick}
|
||||
startIcon={<PersonIcon />}
|
||||
color={isAdmin ? 'secondary' : 'inherit'}
|
||||
sx={{ my: 1, mx: 1.5 }}
|
||||
>
|
||||
Profil
|
||||
</Button>
|
||||
<Menu
|
||||
disableScrollLock={true}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={this.handleUserMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<MenuItem component={Link} to="/profile" onClick={this.handleUserMenuClose}>Profil</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#cart" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellabschluss</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#orders" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellungen</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#settings" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Einstellungen</MenuItem>
|
||||
<Divider />
|
||||
{isAdmin ? <MenuItem component={Link} to="/admin" onClick={this.handleUserMenuClose}>Admin Dashboard</MenuItem> : null}
|
||||
{isAdmin ? <MenuItem component={Link} to="/admin/users" onClick={this.handleUserMenuClose}>Admin Users</MenuItem> : null}
|
||||
<MenuItem onClick={this.handleLogout}>Abmelden</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={this.handleOpen}
|
||||
sx={{ my: 1, mx: 1.5 }}
|
||||
>
|
||||
Anmelden
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleCloseProp || this.handleClose}
|
||||
disableScrollLock
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
>
|
||||
<DialogTitle sx={{ bgcolor: 'white', pb: 0 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h6" color="#2e7d32" fontWeight="bold">
|
||||
{tabValue === 0 ? 'Anmelden' : 'Registrieren'}
|
||||
</Typography>
|
||||
<IconButton edge="end" onClick={this.handleClose} aria-label="close">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={this.handleTabChange}
|
||||
variant="fullWidth"
|
||||
sx={{ mb: 2 }}
|
||||
TabIndicatorProps={{
|
||||
style: { backgroundColor: '#2e7d32' }
|
||||
}}
|
||||
textColor="inherit"
|
||||
>
|
||||
<Tab
|
||||
label="ANMELDEN"
|
||||
sx={{
|
||||
color: tabValue === 0 ? '#2e7d32' : 'inherit',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="REGISTRIEREN"
|
||||
sx={{
|
||||
color: tabValue === 1 ? '#2e7d32' : 'inherit',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
|
||||
{/* Google Sign In Button */}
|
||||
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', mb: 2 }}>
|
||||
{!privacyConfirmed && (
|
||||
<Typography variant="caption" sx={{ mb: 1, textAlign: 'center' }}>
|
||||
Mit dem Click auf "Mit Google anmelden" akzeptiere ich die <Link to="/datenschutz" style={{ color: '#4285F4' }}>Datenschutzbestimmungen</Link>
|
||||
</Typography>
|
||||
)}
|
||||
{!showGoogleAuth && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonIcon />}
|
||||
onClick={() => {
|
||||
sessionStorage.setItem('privacyConfirmed', 'true');
|
||||
this.setState({ showGoogleAuth: true, privacyConfirmed: true });
|
||||
}}
|
||||
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
|
||||
>
|
||||
Mit Google anmelden
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showGoogleAuth && (
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
{/* OR Divider */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', my: 2 }}>
|
||||
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
|
||||
<Typography variant="body2" sx={{ px: 2, color: '#757575' }}>ODER</Typography>
|
||||
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
|
||||
|
||||
|
||||
<Box sx={{ py: 1 }}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={email}
|
||||
onChange={(e) => this.setState({ email: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={password}
|
||||
onChange={(e) => this.setState({ password: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{tabValue === 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1, mb: 1 }}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={this.handleForgotPassword}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
textTransform: 'none',
|
||||
'&:hover': { backgroundColor: 'transparent', textDecoration: 'underline' }
|
||||
}}
|
||||
>
|
||||
Passwort vergessen?
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tabValue === 1 && (
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Passwort bestätigen"
|
||||
type="password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => this.setState({ confirmPassword: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" mt={2}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={tabValue === 0 ? this.handleLogin : this.handleRegister}
|
||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||
>
|
||||
{tabValue === 0 ? 'ANMELDEN' : 'REGISTRIEREN'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CartSyncDialog
|
||||
open={cartSyncOpen}
|
||||
localCart={localCartSync}
|
||||
serverCart={serverCartSync}
|
||||
onClose={() => {
|
||||
const { pendingNavigate } = this.state;
|
||||
this.setState({ cartSyncOpen: false, pendingNavigate: null });
|
||||
this.handleClose();
|
||||
if (pendingNavigate) pendingNavigate();
|
||||
}}
|
||||
onConfirm={this.handleCartSyncConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(LoginComponent);
|
||||
365
src/components/Product.js
Normal file
@@ -0,0 +1,365 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
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 CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddToCartButton from './AddToCartButton.js';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
|
||||
class Product extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._isMounted = false;
|
||||
|
||||
if (!window.smallPicCache) {
|
||||
window.smallPicCache = {};
|
||||
}
|
||||
|
||||
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};
|
||||
this.props.socket.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;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
}
|
||||
|
||||
handleQuantityChange = (quantity) => {
|
||||
console.log(`Product: ${this.props.name}, Quantity: ${quantity}`);
|
||||
// In a real app, this would update a cart state in a parent component or Redux store
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
id, name, price, available, manufacturer, seoName,
|
||||
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);
|
||||
const showThcBadge = thc > 0;
|
||||
let thcBadgeColor = '#4caf50'; // Green default
|
||||
if (thc > 30) {
|
||||
thcBadgeColor = '#f44336'; // Red for > 30
|
||||
} else if (thc > 25) {
|
||||
thcBadgeColor = '#ffeb3b'; // Yellow for > 25
|
||||
}
|
||||
const showFloweringWeeksBadge = floweringWeeks > 0;
|
||||
let floweringWeeksBadgeColor = '#4caf50'; // Green default
|
||||
if (floweringWeeks > 12) {
|
||||
floweringWeeksBadgeColor = '#f44336'; // Red for > 12
|
||||
} else if (floweringWeeks > 8) {
|
||||
floweringWeeksBadgeColor = '#ffeb3b'; // Yellow for > 8
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
width: { xs: '100%', sm: 'auto' }
|
||||
}}>
|
||||
{isNew && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-15px',
|
||||
right: '-15px',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
zIndex: 999,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
{/* Background star - slightly larger and rotated */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="56"
|
||||
height="56"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-3px',
|
||||
left: '-3px',
|
||||
transform: 'rotate(20deg)'
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#20403a"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Middle star - medium size with different rotation */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="53"
|
||||
height="53"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-1.5px',
|
||||
left: '-1.5px',
|
||||
transform: 'rotate(-25deg)'
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#40736b"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Foreground star - main star with text */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="50"
|
||||
height="50"
|
||||
>
|
||||
<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: '45%',
|
||||
left: '45%',
|
||||
transform: 'translate(-50%, -50%) rotate(-10deg)',
|
||||
color: 'white',
|
||||
fontWeight: '900',
|
||||
fontSize: '16px',
|
||||
textShadow: '0px 1px 2px rgba(0,0,0,0.5)',
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
NEU
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
sx={{
|
||||
width: { xs: 'calc(100vw - 48px)', sm: '250px' },
|
||||
minWidth: { xs: 'calc(100vw - 48px)', sm: '250px' },
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '8px',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0px 10px 20px rgba(0,0,0,0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showThcBadge && (
|
||||
<div aria-label={`THC Anteil: ${thc}%`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
backgroundColor: thcBadgeColor,
|
||||
color: thc > 25 && thc <= 30 ? '#000000' : '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
padding: '2px 0',
|
||||
width: '80px',
|
||||
textAlign: 'center',
|
||||
zIndex: 999,
|
||||
fontSize: '9px',
|
||||
boxShadow: '0px 2px 4px rgba(0,0,0,0.2)',
|
||||
transform: 'rotate(-45deg) translateX(-40px) translateY(15px)',
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
THC {thc}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFloweringWeeksBadge && (
|
||||
<div aria-label={`Flowering Weeks: ${floweringWeeks}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
backgroundColor: floweringWeeksBadgeColor,
|
||||
color: floweringWeeks > 8 && floweringWeeks <= 12 ? '#000000' : '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
padding: '1px 0',
|
||||
width: '100px',
|
||||
textAlign: 'center',
|
||||
zIndex: 999,
|
||||
fontSize: '9px',
|
||||
boxShadow: '0px 2px 4px rgba(0,0,0,0.2)',
|
||||
transform: 'rotate(-45deg) translateX(-50px) translateY(32px)',
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
{floweringWeeks} Wochen
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/Artikel/${seoName}`}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit'
|
||||
}}
|
||||
>
|
||||
<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.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 < 600 ? "240" : "180" }
|
||||
image={this.state.image}
|
||||
alt={name}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<CardContent sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'&.MuiCardContent-root:last-child': {
|
||||
paddingBottom: 0
|
||||
}
|
||||
}}>
|
||||
<Typography
|
||||
gutterBottom
|
||||
variant="h6"
|
||||
component="h2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minHeight: '3.4em'
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
</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>
|
||||
{/*incoming*/}
|
||||
</CardContent>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2, pt: 0, display: 'flex', alignItems: 'center' }}>
|
||||
<IconButton
|
||||
component={Link}
|
||||
to={`/Artikel/${seoName}`}
|
||||
size="small"
|
||||
sx={{ mr: 1, color: 'text.secondary' }}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</IconButton>
|
||||
<AddToCartButton cartButton={true} availableSupplier={availableSupplier} available={available} incoming={incoming} seoName={seoName} pictureList={this.props.pictureList} id={id} price={price} vat={vat} weight={weight} name={name} versandklasse={versandklasse}/>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Product;
|
||||
572
src/components/ProductDetailPage.js
Normal file
@@ -0,0 +1,572 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Typography, CardMedia, Stack, Chip } from "@mui/material";
|
||||
import { Link } from "react-router-dom";
|
||||
import parse from "html-react-parser";
|
||||
import AddToCartButton from "./AddToCartButton.js";
|
||||
import Images from "./Images.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();
|
||||
};
|
||||
|
||||
// Product detail page with image loading
|
||||
class ProductDetailPage extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
if (
|
||||
window.productDetailCache &&
|
||||
window.productDetailCache[this.props.seoName]
|
||||
) {
|
||||
this.state = {
|
||||
product: window.productDetailCache[this.props.seoName],
|
||||
loading: false,
|
||||
error: null,
|
||||
attributeImages: {},
|
||||
attributes: [],
|
||||
isSteckling: false,
|
||||
imageDialogOpen: false,
|
||||
};
|
||||
} else {
|
||||
this.state = {
|
||||
product: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
attributeImages: {},
|
||||
attributes: [],
|
||||
isSteckling: false,
|
||||
imageDialogOpen: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadProductData();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.seoName !== this.props.seoName)
|
||||
this.setState(
|
||||
{ product: null, loading: true, error: null, imageDialogOpen: false },
|
||||
this.loadProductData
|
||||
);
|
||||
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected && this.state.loading) {
|
||||
// Socket just connected and we're still loading, retry loading data
|
||||
this.loadProductData();
|
||||
}
|
||||
}
|
||||
|
||||
loadProductData = () => {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to load product data");
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit(
|
||||
"getProductView",
|
||||
{ seoName: this.props.seoName },
|
||||
(res) => {
|
||||
if (res.success) {
|
||||
res.product.seoName = this.props.seoName;
|
||||
this.setState({
|
||||
product: res.product,
|
||||
loading: false,
|
||||
error: null,
|
||||
imageDialogOpen: false,
|
||||
attributes: res.attributes
|
||||
});
|
||||
console.log("getProductView", res);
|
||||
|
||||
// Initialize window-level attribute image cache if it doesn't exist
|
||||
if (!window.attributeImageCache) {
|
||||
window.attributeImageCache = {};
|
||||
}
|
||||
|
||||
if (res.attributes && res.attributes.length > 0) {
|
||||
const attributeImages = {};
|
||||
|
||||
for (const attribute of res.attributes) {
|
||||
const cacheKey = attribute.kMerkmalWert;
|
||||
|
||||
if (attribute.cName == "Anzahl")
|
||||
this.setState({ isSteckling: true });
|
||||
|
||||
// Check if we have a cached result (either URL or negative result)
|
||||
if (window.attributeImageCache[cacheKey]) {
|
||||
const cached = window.attributeImageCache[cacheKey];
|
||||
if (cached.url) {
|
||||
// Use cached URL
|
||||
attributeImages[cacheKey] = cached.url;
|
||||
}
|
||||
} else {
|
||||
// Not in cache, fetch from server
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit(
|
||||
"getAttributePicture",
|
||||
{ id: cacheKey },
|
||||
(res) => {
|
||||
console.log("getAttributePicture", res);
|
||||
if (res.success && !res.noPicture) {
|
||||
const blob = new Blob([res.imageBuffer], {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Cache the successful URL
|
||||
window.attributeImageCache[cacheKey] = {
|
||||
url: url,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Update state and force re-render
|
||||
this.setState(prevState => ({
|
||||
attributeImages: {
|
||||
...prevState.attributeImages,
|
||||
[cacheKey]: url
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
// Cache negative result to avoid future requests
|
||||
// This handles both failure cases and success with noPicture: true
|
||||
window.attributeImageCache[cacheKey] = {
|
||||
noImage: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial state with cached images
|
||||
if (Object.keys(attributeImages).length > 0) {
|
||||
this.setState({ attributeImages });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"Error loading product:",
|
||||
res.error || "Unknown error",
|
||||
res
|
||||
);
|
||||
this.setState({
|
||||
product: null,
|
||||
loading: false,
|
||||
error: "Error loading product",
|
||||
imageDialogOpen: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleOpenDialog = () => {
|
||||
this.setState({ imageDialogOpen: true });
|
||||
};
|
||||
|
||||
handleCloseDialog = () => {
|
||||
this.setState({ imageDialogOpen: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { product, loading, error, attributeImages, isSteckling, attributes } =
|
||||
this.state;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Produkt wird geladen...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ p: 4, textAlign: "center" }}>
|
||||
<Typography variant="h5" gutterBottom color="error">
|
||||
Fehler
|
||||
</Typography>
|
||||
<Typography>{error}</Typography>
|
||||
<Link to="/" style={{ textDecoration: "none" }}>
|
||||
<Typography color="primary" sx={{ mt: 2 }}>
|
||||
Zurück zur Startseite
|
||||
</Typography>
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<Box sx={{ p: 4, textAlign: "center" }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Produkt nicht gefunden
|
||||
</Typography>
|
||||
<Typography>
|
||||
Das gesuchte Produkt existiert nicht oder wurde entfernt.
|
||||
</Typography>
|
||||
<Link to="/" style={{ textDecoration: "none" }}>
|
||||
<Typography color="primary" sx={{ mt: 2 }}>
|
||||
Zurück zur Startseite
|
||||
</Typography>
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// Format price with tax
|
||||
const priceWithTax = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(product.price);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: { xs: 2, md: 2 },
|
||||
pb: { xs: 4, md: 8 },
|
||||
maxWidth: "1400px",
|
||||
mx: "auto",
|
||||
}}
|
||||
>
|
||||
{/* Breadcrumbs */}
|
||||
<Box
|
||||
sx={{
|
||||
mb: 2,
|
||||
position: ["-webkit-sticky", "sticky"], // Provide both prefixed and standard
|
||||
top: {
|
||||
xs: "80px",
|
||||
sm: "80px",
|
||||
md: "80px",
|
||||
lg: "80px",
|
||||
} /* Offset to sit below the header 120 mith menu for md and lg*/,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
zIndex: (theme) =>
|
||||
theme.zIndex.appBar - 1 /* Just below the AppBar */,
|
||||
py: 0,
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
ml: { xs: 0, md: 0 },
|
||||
display: "inline-flex",
|
||||
px: 0,
|
||||
py: 1,
|
||||
backgroundColor: "#2e7d32", //primary dark green
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => this.props.navigate(-1)}
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
textDecoration: "none",
|
||||
color: "#fff",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<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)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: { xs: "100%", sm: "555px" },
|
||||
maxWidth: "100%",
|
||||
minHeight: "400px",
|
||||
background: "#f8f8f8",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{!product.pictureList && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="400"
|
||||
image="/assets/images/nopicture.jpg"
|
||||
alt={product.name}
|
||||
sx={{ objectFit: "cover" }}
|
||||
/>
|
||||
)}
|
||||
{product.pictureList && (
|
||||
<Images
|
||||
socket={this.props.socket}
|
||||
pictureList={product.pictureList}
|
||||
fullscreenOpen={this.state.imageDialogOpen}
|
||||
onOpenFullscreen={this.handleOpenDialog}
|
||||
onCloseFullscreen={this.handleCloseDialog}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Product Details */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: "1 1 60%",
|
||||
p: { xs: 2, md: 4 },
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Product identifiers */}
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Artikelnummer: {product.articleNumber} {product.gtin ? ` | GTIN: ${product.gtin}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Product title */}
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 600, color: "#333" }}
|
||||
>
|
||||
{cleanProductName(product.name)}
|
||||
</Typography>
|
||||
|
||||
{/* Manufacturer if available */}
|
||||
{product.manufacturer && (
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontStyle: "italic" }}>
|
||||
Hersteller: {product.manufacturer}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Attribute images and chips */}
|
||||
{(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && (
|
||||
<Stack direction="row" spacing={2} sx={{ flexWrap: "wrap", gap: 1, mb: 2 }}>
|
||||
{attributes
|
||||
.filter(attribute => attributeImages[attribute.kMerkmalWert])
|
||||
.map((attribute) => {
|
||||
const key = attribute.kMerkmalWert;
|
||||
return (
|
||||
<Box key={key} sx={{ mb: 1 }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={attributeImages[key]}
|
||||
alt={`Attribute ${key}`}
|
||||
sx={{
|
||||
maxWidth: "100px",
|
||||
maxHeight: "100px",
|
||||
objectFit: "contain",
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{attributes
|
||||
.filter(attribute => !attributeImages[attribute.kMerkmalWert])
|
||||
.map((attribute) => (
|
||||
<Chip
|
||||
key={attribute.kMerkmalWert}
|
||||
label={attribute.cWert}
|
||||
disabled
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Weight */}
|
||||
{product.weight > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Gewicht: {product.weight.toFixed(1).replace(".", ",")} kg
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Price and availability section */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: "auto",
|
||||
p: 3,
|
||||
background: "#f9f9f9",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
justifyContent: "space-between",
|
||||
alignItems: { xs: "flex-start", sm: "flex-start" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h4"
|
||||
color="primary"
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{priceWithTax}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
inkl. {product.vat}% MwSt.
|
||||
</Typography>
|
||||
{product.versandklasse &&
|
||||
product.versandklasse != "standard" &&
|
||||
product.versandklasse != "kostenlos" && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{product.versandklasse}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
gap: 2,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{isSteckling && product.available == 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<AddToCartButton
|
||||
steckling={true}
|
||||
cartButton={true}
|
||||
seoName={product.seoName}
|
||||
pictureList={product.pictureList}
|
||||
available={product.available}
|
||||
id={product.id + "steckling"}
|
||||
price={0}
|
||||
vat={product.vat}
|
||||
weight={product.weight}
|
||||
availableSupplier={product.availableSupplier}
|
||||
name={cleanProductName(product.name) + " Stecklingsvorbestellung 1 Stück"}
|
||||
versandklasse={"nur Abholung"}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
mt: 1
|
||||
}}
|
||||
>
|
||||
Abholpreis: 19,90 € pro Steckling.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<AddToCartButton
|
||||
cartButton={true}
|
||||
seoName={product.seoName}
|
||||
pictureList={product.pictureList}
|
||||
available={product.available}
|
||||
id={product.id}
|
||||
availableSupplier={product.availableSupplier}
|
||||
price={product.price}
|
||||
vat={product.vat}
|
||||
weight={product.weight}
|
||||
name={cleanProductName(product.name)}
|
||||
versandklasse={product.versandklasse}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
mt: 1
|
||||
}}
|
||||
>
|
||||
{product.id.toString().endsWith("steckling") ? "Lieferzeit: 14 Tage" :
|
||||
product.available == 1 ? "Lieferzeit: 2-3 Tage" :
|
||||
product.availableSupplier == 1 ? "Lieferzeit: 7-9 Tage" : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Product full description */}
|
||||
{product.description && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 4,
|
||||
p: 4,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
lineHeight: 1.7,
|
||||
"& p": { mt: 0, mb: 2 },
|
||||
"& strong": { fontWeight: 600 },
|
||||
}}
|
||||
>
|
||||
{parse(product.description)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductDetailPage;
|
||||
19
src/components/ProductDetailWithSocket.js
Normal file
@@ -0,0 +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';
|
||||
|
||||
// Wrapper component for individual product detail page with socket
|
||||
const ProductDetailWithSocket = () => {
|
||||
const { seoName } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<SocketContext.Consumer>
|
||||
{socket => <ProductDetailPage seoName={seoName} navigate={navigate} location={location} socket={socket} />}
|
||||
</SocketContext.Consumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailWithSocket;
|
||||
256
src/components/ProductFilters.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { Component } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Filter from './Filter.js';
|
||||
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// HOC to provide router props to class components
|
||||
const withRouter = (ClassComponent) => {
|
||||
return (props) => {
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return <ClassComponent
|
||||
{...props}
|
||||
params={params}
|
||||
searchParams={searchParams}
|
||||
navigate={navigate}
|
||||
location={location}
|
||||
/>;
|
||||
};
|
||||
};
|
||||
|
||||
class ProductFilters extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const uniqueManufacturerArray = this._getUniqueManufacturers(this.props.products);
|
||||
const attributeGroups = this._getAttributeGroups(this.props.attributes);
|
||||
const availabilityValues = this._getAvailabilityValues(this.props.products);
|
||||
|
||||
this.state = {
|
||||
availabilityValues,
|
||||
uniqueManufacturerArray,
|
||||
attributeGroups
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Measure the available space dynamically
|
||||
this.adjustPaperHeight();
|
||||
// Add event listener for window resize
|
||||
window.addEventListener('resize', this.adjustPaperHeight);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Remove event listener when component unmounts
|
||||
window.removeEventListener('resize', this.adjustPaperHeight);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Get viewport height
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Get the offset top position of our paper element
|
||||
const paperTop = paperEl.getBoundingClientRect().top;
|
||||
|
||||
// Estimate footer height (adjust as needed)
|
||||
const footerHeight = 80; // Reduce from 150px
|
||||
|
||||
// Calculate available space and set height
|
||||
const availableHeight = viewportHeight - paperTop - footerHeight;
|
||||
// Add a smaller buffer margin to prevent scrolling but get closer to footer
|
||||
const heightWithBuffer = availableHeight - 20; // Reduce buffer from 50px to 20px
|
||||
paperEl.style.minHeight = `${heightWithBuffer}px`;
|
||||
}
|
||||
|
||||
_getUniqueManufacturers = (products) => {
|
||||
const manufacturers = {};
|
||||
|
||||
for (const product of products)
|
||||
if (!manufacturers[product.manufacturerId])
|
||||
manufacturers[product.manufacturerId] = product.manufacturer;
|
||||
|
||||
const uniqueManufacturerArray = Object.entries(manufacturers)
|
||||
.filter(([_id, name]) => name !== null) // Filter out null names
|
||||
.map(([id, name]) => ({
|
||||
id: parseInt(id),
|
||||
name: name
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return uniqueManufacturerArray;
|
||||
}
|
||||
|
||||
_getAvailabilityValues = (products) => {
|
||||
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:'Neu'});
|
||||
}
|
||||
if(!product.available && product.incomingDate){
|
||||
if(!filters.find(filter => filter.id == 3)) filters.push({id:3,name:'Bald verfügbar'});
|
||||
}
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
_getAttributeGroups = (attributes) => {
|
||||
const attributeGroups = {};
|
||||
if(attributes) for(const attribute of attributes) {
|
||||
if(!attributeGroups[attribute.cName]) attributeGroups[attribute.cName] = {name:attribute.cName, values:{}};
|
||||
attributeGroups[attribute.cName].values[attribute.kMerkmalWert] = {id:attribute.kMerkmalWert, name:attribute.cWert};
|
||||
}
|
||||
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)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for(const attributeGroup of sortedAttributeGroups) {
|
||||
const filter = (
|
||||
<Filter
|
||||
key={`attr-filter-${attributeGroup.name}`}
|
||||
title={attributeGroup.name}
|
||||
options={Object.values(attributeGroup.values)}
|
||||
filterType="attribute"
|
||||
products={this.props.products}
|
||||
filteredProducts={this.props.filteredProducts}
|
||||
attributes={this.props.attributes}
|
||||
onFilterChange={(msg)=>{
|
||||
if(msg.value) {
|
||||
setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');
|
||||
} else {
|
||||
removeSessionSetting(`filter_${msg.type}_${msg.name}`);
|
||||
}
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
filters.push(filter);
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Paper
|
||||
id="filters-paper"
|
||||
elevation={1}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
|
||||
{this.props.dataType == 'category' && (
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h1"
|
||||
sx={{
|
||||
mb: 4,
|
||||
fontFamily: 'SwashingtonCP',
|
||||
color: 'primary.main'
|
||||
}}
|
||||
>
|
||||
{this.props.dataParam}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
|
||||
{this.props.products.length > 0 && (
|
||||
<><Filter
|
||||
title="Verfügbarkeit"
|
||||
options={this.state.availabilityValues}
|
||||
searchParams={this.props.searchParams}
|
||||
products={this.props.products}
|
||||
filteredProducts={this.props.filteredProducts}
|
||||
attributes={this.props.attributes}
|
||||
filterType="availability"
|
||||
onFilterChange={(msg)=>{
|
||||
|
||||
if(msg.resetAll) {
|
||||
sessionStorage.removeItem('filter_availability');
|
||||
clearAllSessionSettings();
|
||||
this.props.onFilterChange();
|
||||
return;
|
||||
}
|
||||
|
||||
if(!msg.value) {
|
||||
console.log('msg',msg);
|
||||
if(msg.name == '1') sessionStorage.setItem('filter_availability', msg.name);
|
||||
if(msg.name != '1') removeSessionSetting(`filter_${msg.type}_${msg.name}`);
|
||||
//this.props.navigate({
|
||||
// pathname: this.props.location.pathname,
|
||||
// search: `?inStock=${msg.name}`
|
||||
//});
|
||||
} else {
|
||||
if(msg.name == '1') sessionStorage.removeItem('filter_availability');
|
||||
if(msg.name != '1') setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');
|
||||
console.log('msg',msg);
|
||||
//this.props.navigate({
|
||||
// pathname: this.props.location.pathname,
|
||||
// search: this.props.location.search.replace(/inStock=[^&]*/, '')
|
||||
//});
|
||||
}
|
||||
|
||||
this.props.onFilterChange();
|
||||
|
||||
|
||||
}}
|
||||
/>
|
||||
|
||||
{this.generateAttributeFilters()}
|
||||
|
||||
<Filter
|
||||
title="Hersteller"
|
||||
options={this.state.uniqueManufacturerArray}
|
||||
filterType="manufacturer"
|
||||
products={this.props.products}
|
||||
filteredProducts={this.props.filteredProducts}
|
||||
attributes={this.props.attributes}
|
||||
onFilterChange={(msg)=>{
|
||||
if(msg.value) {
|
||||
setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');
|
||||
} else {
|
||||
removeSessionSetting(`filter_${msg.type}_${msg.name}`);
|
||||
}
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
/>
|
||||
</>)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(ProductFilters);
|
||||
347
src/components/ProductList.js
Normal file
@@ -0,0 +1,347 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Pagination from '@mui/material/Pagination';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Product from './Product.js';
|
||||
import { removeSessionSetting } from '../utils/sessionStorage.js';
|
||||
|
||||
// Sort products by fuzzy similarity to their name/description
|
||||
function sortProductsByFuzzySimilarity(products, searchTerm) {
|
||||
console.log('sortProductsByFuzzySimilarity',products,searchTerm);
|
||||
// Create an array that preserves the product object and its searchable text
|
||||
const productsWithText = products.map(product => {
|
||||
const searchableText = `${product.name || ''} ${product.description || ''}`;
|
||||
return { product, searchableText };
|
||||
});
|
||||
|
||||
// Sort products based on their searchable text similarity
|
||||
productsWithText.sort((a, b) => {
|
||||
const scoreA = getFuzzySimilarityScore(a.searchableText, searchTerm);
|
||||
const scoreB = getFuzzySimilarityScore(b.searchableText, searchTerm);
|
||||
return scoreB - scoreA; // Higher scores first
|
||||
});
|
||||
|
||||
// Return just the sorted product objects
|
||||
return productsWithText.map(item => item.product);
|
||||
}
|
||||
|
||||
// Calculate a similarity score between text and search term
|
||||
function getFuzzySimilarityScore(text, searchTerm) {
|
||||
const searchWords = searchTerm.toLowerCase().split(/\W+/).filter(Boolean);
|
||||
const textWords = text.toLowerCase().split(/\W+/).filter(Boolean);
|
||||
|
||||
let totalScore = 0;
|
||||
for (let searchWord of searchWords) {
|
||||
// Exact matches get highest priority
|
||||
if (textWords.includes(searchWord)) {
|
||||
totalScore += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Partial matches get scored based on similarity
|
||||
let bestMatch = 0;
|
||||
for (let textWord of textWords) {
|
||||
if (textWord.includes(searchWord) || searchWord.includes(textWord)) {
|
||||
const similarity = Math.min(searchWord.length, textWord.length) /
|
||||
Math.max(searchWord.length, textWord.length);
|
||||
if (similarity > bestMatch) bestMatch = similarity;
|
||||
}
|
||||
}
|
||||
totalScore += bestMatch;
|
||||
}
|
||||
|
||||
return totalScore;
|
||||
}
|
||||
|
||||
|
||||
class ProductList extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
viewMode: window.productListViewMode || 'grid',
|
||||
products:[],
|
||||
page: window.productListPage || 1,
|
||||
itemsPerPage: window.productListItemsPerPage || 20,
|
||||
sortBy: window.currentSearchQuery ? 'searchField' : 'name'
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
this.handleSearchQuery = () => {
|
||||
this.setState({ sortBy: window.currentSearchQuery ? 'searchField' : 'name' });
|
||||
};
|
||||
window.addEventListener('search-query-change', this.handleSearchQuery);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('search-query-change', this.handleSearchQuery);
|
||||
}
|
||||
|
||||
handleViewModeChange = (viewMode) => {
|
||||
this.setState({ viewMode });
|
||||
window.productListViewMode = viewMode;
|
||||
}
|
||||
|
||||
handlePageChange = (event, value) => {
|
||||
this.setState({ page: value });
|
||||
window.productListPage = value;
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const currentPageCapacity = this.state.itemsPerPage === 'all' ? Infinity : this.state.itemsPerPage;
|
||||
if(this.props.products.length > 0 ) if (this.props.products.length < (currentPageCapacity * (this.state.page-1)) ) {
|
||||
if(this.state.page != 1) this.setState({ page: 1 });
|
||||
window.productListPage = 1;
|
||||
}
|
||||
}
|
||||
|
||||
handleProductsPerPageChange = (event) => {
|
||||
const newItemsPerPage = event.target.value;
|
||||
const newState = { itemsPerPage: newItemsPerPage };
|
||||
window.productListItemsPerPage = newItemsPerPage;
|
||||
|
||||
if(newItemsPerPage!=='all'){
|
||||
const newTotalPages = Math.ceil(this.props.products.length / newItemsPerPage);
|
||||
if (this.state.page > newTotalPages) {
|
||||
newState.page = newTotalPages;
|
||||
window.productListPage = newTotalPages;
|
||||
}
|
||||
}
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
handleSortChange = (event) => {
|
||||
const sortBy = event.target.value;
|
||||
this.setState({ sortBy });
|
||||
}
|
||||
|
||||
renderPagination = (pages, page) => {
|
||||
return (
|
||||
<Box sx={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'left' }}>
|
||||
{((this.state.itemsPerPage==='all')||(this.props.products.length<this.state.itemsPerPage))?null:
|
||||
<Pagination
|
||||
count={pages}
|
||||
page={page}
|
||||
onChange={this.handlePageChange}
|
||||
color="primary"
|
||||
size={"large"}
|
||||
siblingCount={window.innerWidth < 600 ? 0 : 1}
|
||||
boundaryCount={window.innerWidth < 600 ? 1 : 1}
|
||||
hideNextButton={false}
|
||||
hidePrevButton={false}
|
||||
showFirstButton={window.innerWidth >= 600}
|
||||
showLastButton={window.innerWidth >= 600}
|
||||
sx={{
|
||||
'& .MuiPagination-ul': {
|
||||
flexWrap: 'nowrap',
|
||||
overflowX: 'auto',
|
||||
maxWidth: '100%'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
//console.log('products',this.props.activeAttributeFilters,this.props.activeManufacturerFilters,window.currentSearchQuery,this.state.sortBy);
|
||||
|
||||
const filteredProducts = (this.state.sortBy==='searchField')&&(window.currentSearchQuery)?sortProductsByFuzzySimilarity(this.props.products, window.currentSearchQuery):this.state.sortBy==='name'?this.props.products:this.props.products.sort((a,b)=>{
|
||||
if(this.state.sortBy==='price-low-high'){
|
||||
return a.price-b.price;
|
||||
}
|
||||
if(this.state.sortBy==='price-high-low'){
|
||||
return b.price-a.price;
|
||||
}
|
||||
});
|
||||
const products = this.state.itemsPerPage==='all'?[...filteredProducts]:filteredProducts.slice((this.state.page - 1) * this.state.itemsPerPage , this.state.page * this.state.itemsPerPage);
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%' }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{this.props.activeAttributeFilters.map((filter,index) => (
|
||||
<Chip
|
||||
size="medium"
|
||||
key={index}
|
||||
label={filter.value}
|
||||
onClick={() => {
|
||||
removeSessionSetting(`filter_attribute_${filter.id}`);
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
onDelete={() => {
|
||||
removeSessionSetting(`filter_attribute_${filter.id}`);
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
clickable
|
||||
/>
|
||||
))}
|
||||
{this.props.activeManufacturerFilters.map((filter,index) => (
|
||||
<Chip
|
||||
size="medium"
|
||||
key={index}
|
||||
label={filter.name}
|
||||
onClick={() => {
|
||||
removeSessionSetting(`filter_manufacturer_${filter.value}`);
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
onDelete={() => {
|
||||
removeSessionSetting(`filter_manufacturer_${filter.value}`);
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
clickable
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
{/* Sort Dropdown */}
|
||||
<FormControl variant="outlined" size="small" sx={{ minWidth: 140 }}>
|
||||
<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="Sortierung"
|
||||
MenuProps={{
|
||||
disableScrollLock: true,
|
||||
anchorOrigin: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
},
|
||||
PaperProps: {
|
||||
sx: {
|
||||
maxHeight: 200,
|
||||
boxShadow: 3,
|
||||
mt: 0.5
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Per Page Dropdown */}
|
||||
<FormControl variant="outlined" size="small" sx={{ minWidth: 100 }}>
|
||||
<InputLabel id="products-per-page-label">pro Seite</InputLabel>
|
||||
<Select
|
||||
labelId="products-per-page-label"
|
||||
value={this.state.itemsPerPage}
|
||||
onChange={this.handleProductsPerPageChange}
|
||||
label="pro Seite"
|
||||
MenuProps={{
|
||||
disableScrollLock: true,
|
||||
anchorOrigin: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
},
|
||||
PaperProps: {
|
||||
sx: {
|
||||
maxHeight: 200,
|
||||
boxShadow: 3,
|
||||
mt: 0.5,
|
||||
position: 'absolute',
|
||||
zIndex: 999
|
||||
}
|
||||
},
|
||||
container: document.getElementById('root')
|
||||
}}
|
||||
>
|
||||
<MenuItem value={20}>20</MenuItem>
|
||||
<MenuItem value={50}>50</MenuItem>
|
||||
<MenuItem value="all">Alle</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
{ this.renderPagination(Math.ceil(filteredProducts.length / this.state.itemsPerPage), this.state.page) }
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{/*this.props.dataType == 'category' && (<>Kategorie: {this.props.dataParam}</>)}*/}
|
||||
{this.props.dataType == 'search' && (<>Suchergebnisse für: "{this.props.dataParam}"</>)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{
|
||||
this.props.totalProductCount==this.props.products.length && this.props.totalProductCount>0 ?
|
||||
`${this.props.totalProductCount} Produkte`
|
||||
:
|
||||
`${this.props.products.length} von ${this.props.totalProductCount} Produkte`
|
||||
}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{products.map((product) => (
|
||||
<Grid
|
||||
key={product.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: { xs: 'stretch', sm: 'center' },
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
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}
|
||||
pictureList={product.pictureList}
|
||||
availableSupplier={product.availableSupplier}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{this.renderPagination(Math.ceil(filteredProducts.length / this.state.itemsPerPage), this.state.page)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductList;
|
||||
14
src/components/ScrollToTop.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const ScrollToTop = () => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
121
src/components/Stripe.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { Component, useState } from "react";
|
||||
import { Elements, PaymentElement } from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { Button } from "@mui/material";
|
||||
import config from "../config.js";
|
||||
|
||||
import { useStripe, useElements } from "@stripe/react-stripe-js";
|
||||
|
||||
const CheckoutForm = () => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState(null);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/profile?complete`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error.message);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<PaymentElement />
|
||||
<Button variant="contained" disabled={!stripe} style={{ marginTop: "20px" }} type="submit">
|
||||
Bezahlung Abschließen
|
||||
</Button>
|
||||
{errorMessage && <div>{errorMessage}</div>}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
class Stripe extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
stripe: null,
|
||||
loading: true,
|
||||
elements: null,
|
||||
};
|
||||
this.stripePromise = loadStripe(config.stripePublishableKey);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.stripePromise.then((stripe) => {
|
||||
this.setState({ stripe, loading: false });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { clientSecret } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.state.loading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<Elements
|
||||
stripe={this.stripePromise}
|
||||
options={{
|
||||
appearance: {
|
||||
theme: "stripe",
|
||||
variables: {
|
||||
// Core colors matching your green theme
|
||||
colorPrimary: '#2E7D32', // Your primary forest green
|
||||
colorBackground: '#ffffff', // White background (matches your paper color)
|
||||
colorText: '#33691E', // Your primary text color (dark green)
|
||||
colorTextSecondary: '#558B2F', // Your secondary text color
|
||||
colorTextPlaceholder: '#81C784', // Light green for placeholder text
|
||||
colorDanger: '#D32F2F', // Your error color (red)
|
||||
colorSuccess: '#43A047', // Your success color
|
||||
colorWarning: '#FF9800', // Orange for warnings
|
||||
|
||||
// Typography matching your Roboto setup
|
||||
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif",
|
||||
fontSizeBase: '16px', // Base font size for mobile compatibility
|
||||
fontWeightNormal: '400', // Normal Roboto weight
|
||||
fontWeightMedium: '500', // Medium Roboto weight
|
||||
fontWeightBold: '700', // Bold Roboto weight
|
||||
|
||||
// Layout and spacing
|
||||
spacingUnit: '4px', // Consistent spacing
|
||||
borderRadius: '8px', // Rounded corners matching your style
|
||||
|
||||
// Background variations
|
||||
colorBackgroundDeemphasized: '#C8E6C9', // Your light green background
|
||||
|
||||
// Focus and interaction states
|
||||
focusBoxShadow: '0 0 0 2px #4CAF50', // Green focus ring
|
||||
focusOutline: 'none',
|
||||
|
||||
// Icons to match your green theme
|
||||
iconColor: '#558B2F', // Secondary green for icons
|
||||
iconHoverColor: '#2E7D32', // Primary green on hover
|
||||
}
|
||||
},
|
||||
clientSecret: clientSecret,
|
||||
}}
|
||||
>
|
||||
<CheckoutForm />
|
||||
</Elements>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Stripe;
|
||||
258
src/components/ThemeCustomizerDialog.js
Normal file
@@ -0,0 +1,258 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
TextField,
|
||||
Chip,
|
||||
Grid,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import PaletteIcon from '@mui/icons-material/Palette';
|
||||
|
||||
const ThemeCustomizerDialog = ({ open, onClose, theme, onThemeChange }) => {
|
||||
const [localTheme, setLocalTheme] = useState(theme);
|
||||
|
||||
// @note Theme customizer for development - allows real-time theme changes
|
||||
useEffect(() => {
|
||||
setLocalTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
const handleColorChange = (path, value) => {
|
||||
const pathArray = path.split('.');
|
||||
const newTheme = { ...localTheme };
|
||||
let current = newTheme;
|
||||
|
||||
for (let i = 0; i < pathArray.length - 1; i++) {
|
||||
current = current[pathArray[i]];
|
||||
}
|
||||
current[pathArray[pathArray.length - 1]] = value;
|
||||
|
||||
setLocalTheme(newTheme);
|
||||
onThemeChange(newTheme);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const resetTheme = () => {
|
||||
const defaultTheme = {
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#2E7D32',
|
||||
light: '#4CAF50',
|
||||
dark: '#1B5E20',
|
||||
},
|
||||
secondary: {
|
||||
main: '#81C784',
|
||||
light: '#A5D6A7',
|
||||
dark: '#66BB6A',
|
||||
},
|
||||
background: {
|
||||
default: '#C8E6C9',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
text: {
|
||||
primary: '#33691E',
|
||||
secondary: '#558B2F',
|
||||
},
|
||||
success: {
|
||||
main: '#43A047',
|
||||
},
|
||||
error: {
|
||||
main: '#D32F2F',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif",
|
||||
h4: {
|
||||
fontWeight: 600,
|
||||
color: '#33691E',
|
||||
},
|
||||
},
|
||||
};
|
||||
setLocalTheme(defaultTheme);
|
||||
onThemeChange(defaultTheme);
|
||||
};
|
||||
|
||||
const ColorPicker = ({ label, path, value }) => (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>{label}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TextField
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => handleColorChange(path, e.target.value)}
|
||||
sx={{ width: 50, height: 35 }}
|
||||
/>
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={(e) => handleColorChange(path, e.target.value)}
|
||||
size="small"
|
||||
sx={{ flex: 1, fontSize: '0.75rem' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PaletteIcon />
|
||||
Theme Customizer (Development Mode)
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Chip
|
||||
label="DEV ONLY"
|
||||
color="warning"
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
This tool is only available in development mode for theme customization.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Primary Colors</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Main"
|
||||
path="palette.primary.main"
|
||||
value={localTheme.palette.primary.main}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Light"
|
||||
path="palette.primary.light"
|
||||
value={localTheme.palette.primary.light}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Dark"
|
||||
path="palette.primary.dark"
|
||||
value={localTheme.palette.primary.dark}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Secondary Colors</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Main"
|
||||
path="palette.secondary.main"
|
||||
value={localTheme.palette.secondary.main}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Light"
|
||||
path="palette.secondary.light"
|
||||
value={localTheme.palette.secondary.light}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Dark"
|
||||
path="palette.secondary.dark"
|
||||
value={localTheme.palette.secondary.dark}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Background & Text</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={6}>
|
||||
<ColorPicker
|
||||
label="Background"
|
||||
path="palette.background.default"
|
||||
value={localTheme.palette.background.default}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Paper"
|
||||
path="palette.background.paper"
|
||||
value={localTheme.palette.background.paper}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<ColorPicker
|
||||
label="Text Primary"
|
||||
path="palette.text.primary"
|
||||
value={localTheme.palette.text.primary}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Text Secondary"
|
||||
path="palette.text.secondary"
|
||||
value={localTheme.palette.text.secondary}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Status Colors</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={6}>
|
||||
<ColorPicker
|
||||
label="Success"
|
||||
path="palette.success.main"
|
||||
value={localTheme.palette.success.main}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<ColorPicker
|
||||
label="Error"
|
||||
path="palette.error.main"
|
||||
value={localTheme.palette.error.main}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={resetTheme} color="warning">
|
||||
Reset to Default
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeCustomizerDialog;
|
||||
159
src/components/configurator/ExtrasSelector.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { Component } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
|
||||
class ExtrasSelector extends Component {
|
||||
formatPrice(price) {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
renderExtraCard(extra) {
|
||||
const { selectedExtras, onExtraToggle, showImage = true } = this.props;
|
||||
const isSelected = selectedExtras.includes(extra.id);
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="160"
|
||||
image={extra.image}
|
||||
alt={extra.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onExtraToggle(extra.id);
|
||||
}}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
'&.Mui-checked': { color: '#2e7d32' },
|
||||
padding: 0
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label=""
|
||||
sx={{ margin: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{this.formatPrice(extra.price)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{extra.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{extra.description}
|
||||
</Typography>
|
||||
|
||||
{isSelected && (
|
||||
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
✓ Hinzugefügt
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { extras, title, subtitle, groupByCategory = true, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
|
||||
|
||||
if (groupByCategory) {
|
||||
// Group extras by category
|
||||
const groupedExtras = extras.reduce((acc, extra) => {
|
||||
if (!acc[extra.category]) {
|
||||
acc[extra.category] = [];
|
||||
}
|
||||
acc[extra.category].push(extra);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</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="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{extras.map(extra => (
|
||||
<Grid item {...gridSize} key={extra.id}>
|
||||
{this.renderExtraCard(extra)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ExtrasSelector;
|
||||
170
src/components/configurator/ProductSelector.js
Normal file
@@ -0,0 +1,170 @@
|
||||
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 Chip from '@mui/material/Chip';
|
||||
|
||||
class ProductSelector extends Component {
|
||||
formatPrice(price) {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
renderProductCard(product) {
|
||||
const { selectedValue, onSelect, showImage = true } = this.props;
|
||||
const isSelected = selectedValue === product.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={product.id}
|
||||
sx={{
|
||||
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',
|
||||
height: '100%'
|
||||
}}
|
||||
onClick={() => onSelect(product.id)}
|
||||
>
|
||||
{showImage && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="180"
|
||||
image={product.image}
|
||||
alt={product.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{product.description}
|
||||
</Typography>
|
||||
|
||||
{/* Product specific information */}
|
||||
{this.renderProductDetails(product)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{this.formatPrice(product.price)}
|
||||
</Typography>
|
||||
{isSelected && (
|
||||
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
✓ Ausgewählt
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
renderProductDetails(product) {
|
||||
const { productType } = this.props;
|
||||
|
||||
switch (productType) {
|
||||
case 'tent':
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Maße:</strong> {product.dimensions}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Für:</strong> {product.coverage}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'light':
|
||||
return (
|
||||
<Box sx={{ mt: 2, mb: 2 }}>
|
||||
<Chip
|
||||
label={product.wattage}
|
||||
size="small"
|
||||
sx={{ mr: 1, mb: 1, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Chip
|
||||
label={product.coverage}
|
||||
size="small"
|
||||
sx={{ mr: 1, mb: 1, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Chip
|
||||
label={product.spectrum}
|
||||
size="small"
|
||||
sx={{ mr: 1, mb: 1, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Effizienz: ${product.efficiency}`}
|
||||
size="small"
|
||||
sx={{ mb: 1, pointerEvents: 'none' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'ventilation':
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Luftdurchsatz:</strong> {product.airflow}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Lautstärke:</strong> {product.noiseLevel}
|
||||
</Typography>
|
||||
{product.includes && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>Beinhaltet:</strong>
|
||||
</Typography>
|
||||
{product.includes.map((item, index) => (
|
||||
<Typography key={index} variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
• {item}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { products, title, subtitle, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{products.map(product => (
|
||||
<Grid item {...gridSize} key={product.id}>
|
||||
{this.renderProductCard(product)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductSelector;
|
||||
241
src/components/configurator/TentShapeSelector.js
Normal file
@@ -0,0 +1,241 @@
|
||||
import React, { Component } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
|
||||
class TentShapeSelector extends Component {
|
||||
// Generate plant layout based on tent shape
|
||||
generatePlantLayout(shapeId) {
|
||||
const layouts = {
|
||||
'60x60': [
|
||||
{ x: 50, y: 50, size: 18 } // 1 large plant centered
|
||||
],
|
||||
'80x80': [
|
||||
{ x: 35, y: 35, size: 12 }, // 2x2 = 4 plants
|
||||
{ x: 65, y: 35, size: 12 },
|
||||
{ x: 35, y: 65, size: 12 },
|
||||
{ x: 65, y: 65, size: 12 }
|
||||
],
|
||||
'100x100': [
|
||||
{ x: 22, y: 22, size: 10 }, // 3x3 = 9 plants
|
||||
{ x: 50, y: 22, size: 10 },
|
||||
{ x: 78, y: 22, size: 10 },
|
||||
{ x: 22, y: 50, size: 10 },
|
||||
{ x: 50, y: 50, size: 10 },
|
||||
{ x: 78, y: 50, size: 10 },
|
||||
{ x: 22, y: 78, size: 10 },
|
||||
{ x: 50, y: 78, size: 10 },
|
||||
{ x: 78, y: 78, size: 10 }
|
||||
],
|
||||
'120x60': [
|
||||
{ x: 30, y: 50, size: 14 }, // 1x3 = 3 larger plants
|
||||
{ x: 50, y: 50, size: 14 },
|
||||
{ x: 70, y: 50, size: 14 }
|
||||
]
|
||||
};
|
||||
|
||||
return layouts[shapeId] || [];
|
||||
}
|
||||
|
||||
renderShapeCard(shape) {
|
||||
const { selectedShape, onShapeSelect } = this.props;
|
||||
const isSelected = selectedShape === shape.id;
|
||||
|
||||
const plants = this.generatePlantLayout(shape.id);
|
||||
|
||||
// Make visual sizes proportional to actual dimensions
|
||||
let visualWidth, visualHeight;
|
||||
switch(shape.id) {
|
||||
case '60x60':
|
||||
visualWidth = 90;
|
||||
visualHeight = 90;
|
||||
break;
|
||||
case '80x80':
|
||||
visualWidth = 110;
|
||||
visualHeight = 110;
|
||||
break;
|
||||
case '100x100':
|
||||
visualWidth = 130;
|
||||
visualHeight = 130;
|
||||
break;
|
||||
case '120x60':
|
||||
visualWidth = 140;
|
||||
visualHeight = 80;
|
||||
break;
|
||||
default:
|
||||
visualWidth = 120;
|
||||
visualHeight = 120;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={shape.id}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: '3px solid',
|
||||
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
|
||||
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
|
||||
'&:hover': {
|
||||
boxShadow: 8,
|
||||
borderColor: isSelected ? '#2e7d32' : '#90caf9',
|
||||
transform: 'translateY(-2px)'
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
height: '100%',
|
||||
minHeight: 300
|
||||
}}
|
||||
onClick={() => onShapeSelect(shape.id)}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', p: 3 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{shape.name}
|
||||
</Typography>
|
||||
|
||||
{/* Enhanced visual representation with plant layout */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: visualHeight,
|
||||
mb: 2,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${visualWidth}px`,
|
||||
height: `${visualHeight}px`,
|
||||
border: '3px solid #2e7d32',
|
||||
borderRadius: 2,
|
||||
backgroundColor: isSelected ? '#e8f5e8' : '#f5f5f5',
|
||||
position: 'relative',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Grid pattern */}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<pattern id={`grid-${shape.id}`} width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M 10 0 L 0 0 0 10" fill="none" stroke="#e0e0e0" strokeWidth="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#grid-${shape.id})`} />
|
||||
|
||||
{/* Plants */}
|
||||
{plants.map((plant, index) => (
|
||||
<circle
|
||||
key={index}
|
||||
cx={`${plant.x}%`}
|
||||
cy={`${plant.y}%`}
|
||||
r={plant.size}
|
||||
fill="#4caf50"
|
||||
fillOpacity="0.8"
|
||||
stroke="#2e7d32"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Dimensions label */}
|
||||
<Typography variant="caption" sx={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontWeight: 'bold',
|
||||
color: '#2e7d32',
|
||||
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||
px: 1,
|
||||
borderRadius: 1,
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
{shape.footprint}
|
||||
</Typography>
|
||||
|
||||
{/* Plant count label */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
backgroundColor: 'rgba(46, 125, 50, 0.9)',
|
||||
color: 'white',
|
||||
px: 1,
|
||||
borderRadius: 1,
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{plants.length} 🌱
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{shape.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Chip
|
||||
label={`${shape.minPlants}-${shape.maxPlants} Pflanzen`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: isSelected ? '#2e7d32' : '#f0f0f0',
|
||||
color: isSelected ? 'white' : 'inherit',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
fontWeight: 'bold',
|
||||
opacity: isSelected ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease'
|
||||
}}
|
||||
>
|
||||
✓ Ausgewählt
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { tentShapes, title, subtitle } = this.props;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Grid container spacing={3}>
|
||||
{tentShapes.map(shape => (
|
||||
<Grid item xs={12} sm={6} md={3} key={shape.id}>
|
||||
{this.renderShapeCard(shape)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TentShapeSelector;
|
||||
3
src/components/configurator/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as TentShapeSelector } from './TentShapeSelector.js';
|
||||
export { default as ProductSelector } from './ProductSelector.js';
|
||||
export { default as ExtrasSelector } from './ExtrasSelector.js';
|
||||
198
src/components/header/ButtonGroup.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import LoginComponent from '../LoginComponent.js';
|
||||
import CartDropdown from '../CartDropdown.js';
|
||||
import { isUserLoggedIn } from '../LoginComponent.js';
|
||||
|
||||
function getBadgeNumber() {
|
||||
let count = 0;
|
||||
if (Array.isArray(window.cart)) for (const item of window.cart) {
|
||||
if (item.quantity) count += item.quantity;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
class ButtonGroup extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isCartOpen: false,
|
||||
badgeNumber: getBadgeNumber()
|
||||
};
|
||||
this.isUpdatingFromSocket = false; // @note Flag to prevent socket loop
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.cart = () => {
|
||||
// @note Only emit if socket exists, is connected, AND the update didn't come from socket
|
||||
if (this.props.socket && this.props.socket.connected && !this.isUpdatingFromSocket) {
|
||||
this.props.socket.emit('updateCart', window.cart);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
badgeNumber: getBadgeNumber()
|
||||
});
|
||||
};
|
||||
window.addEventListener('cart', this.cart);
|
||||
|
||||
// Add event listener for the toggle-cart event from AddToCartButton
|
||||
this.toggleCartListener = () => this.toggleCart();
|
||||
window.addEventListener('toggle-cart', this.toggleCartListener);
|
||||
|
||||
// Add socket listeners if socket is available and connected
|
||||
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);
|
||||
window.removeEventListener('toggle-cart', this.toggleCartListener);
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
handleCartUpdated = (id,user,cart) => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(storedUser);
|
||||
if(user && parsedUser &&user.email == parsedUser.email){
|
||||
// @note Set flag before updating cart to prevent socket loop
|
||||
this.isUpdatingFromSocket = true;
|
||||
window.cart = cart;
|
||||
this.setState({
|
||||
badgeNumber: getBadgeNumber()
|
||||
});
|
||||
// @note Reset flag after a short delay to allow for any synchronous events
|
||||
setTimeout(() => {
|
||||
this.isUpdatingFromSocket = false;
|
||||
}, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing user from sessionStorage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
toggleCart = () => {
|
||||
this.setState(prevState => ({
|
||||
isCartOpen: !prevState.isCartOpen
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
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 } }}>
|
||||
|
||||
|
||||
<LoginComponent socket={socket} />
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<Badge badgeContent={this.state.badgeNumber} color="error">
|
||||
<ShoppingCartIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
<Drawer
|
||||
anchor="left"
|
||||
open={isCartOpen}
|
||||
onClose={this.toggleCart}
|
||||
disableScrollLock={true}
|
||||
>
|
||||
<Box sx={{ width: 420, p: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={this.toggleCart}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.dark',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6">Warenkorb</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<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');
|
||||
} else if (window.openLoginDrawer) {
|
||||
window.openLoginDrawer(); // Call global function to open login drawer
|
||||
this.toggleCart(); // Close the cart drawer
|
||||
} else {
|
||||
console.error('openLoginDrawer function not available');
|
||||
}
|
||||
}}/>
|
||||
|
||||
</Box>
|
||||
</Drawer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper for ButtonGroup to provide navigate function
|
||||
const ButtonGroupWithRouter = (props) => {
|
||||
const navigate = useNavigate();
|
||||
return <ButtonGroup {...props} navigate={navigate} />;
|
||||
};
|
||||
|
||||
export default ButtonGroupWithRouter;
|
||||
481
src/components/header/CategoryList.js
Normal file
@@ -0,0 +1,481 @@
|
||||
import React, { Component, Profiler } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Container from "@mui/material/Container";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Link } from "react-router-dom";
|
||||
import HomeIcon from "@mui/icons-material/Home";
|
||||
|
||||
class CategoryList extends Component {
|
||||
findCategoryById = (category, targetId) => {
|
||||
if (!category) return null;
|
||||
|
||||
if (category.seoName === targetId) {
|
||||
return category;
|
||||
}
|
||||
|
||||
if (category.children) {
|
||||
for (let child of category.children) {
|
||||
const found = this.findCategoryById(child, targetId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
getPathToCategory = (category, targetId, currentPath = []) => {
|
||||
if (!category) return null;
|
||||
|
||||
const newPath = [...currentPath, category];
|
||||
|
||||
if (category.seoName === targetId) {
|
||||
return newPath;
|
||||
}
|
||||
|
||||
if (category.children) {
|
||||
for (let child of category.children) {
|
||||
const found = this.getPathToCategory(child, targetId, newPath);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Check for cached data during SSR/initial render
|
||||
let initialState = {
|
||||
categoryTree: null,
|
||||
level1Categories: [], // Children of category 209 (Home) - always shown
|
||||
level2Categories: [], // Children of active level 1 category
|
||||
level3Categories: [], // Children of active level 2 category
|
||||
activePath: [], // Array of active category objects for each level
|
||||
fetchedCategories: false,
|
||||
};
|
||||
|
||||
// Try to get cached data for SSR
|
||||
try {
|
||||
// @note Check both global.window (SSR) and window (browser) for cache
|
||||
const productCache = (typeof global !== "undefined" && global.window && global.window.productCache) ||
|
||||
(typeof window !== "undefined" && window.productCache);
|
||||
|
||||
if (productCache) {
|
||||
const cacheKey = "categoryTree_209";
|
||||
const cachedData = productCache[cacheKey];
|
||||
if (cachedData && cachedData.categoryTree) {
|
||||
const { categoryTree, timestamp } = cachedData;
|
||||
const cacheAge = Date.now() - timestamp;
|
||||
const tenMinutes = 10 * 60 * 1000;
|
||||
|
||||
// Use cached data if it's fresh
|
||||
if (cacheAge < tenMinutes) {
|
||||
initialState.categoryTree = categoryTree;
|
||||
initialState.fetchedCategories = true;
|
||||
|
||||
// Process category tree to set up navigation
|
||||
const level1Categories =
|
||||
categoryTree && categoryTree.id === 209
|
||||
? categoryTree.children || []
|
||||
: [];
|
||||
initialState.level1Categories = level1Categories;
|
||||
|
||||
// Process active category path if needed
|
||||
if (props.activeCategoryId) {
|
||||
const activeCategory = this.findCategoryById(
|
||||
categoryTree,
|
||||
props.activeCategoryId
|
||||
);
|
||||
if (activeCategory) {
|
||||
const pathToActive = this.getPathToCategory(
|
||||
categoryTree,
|
||||
props.activeCategoryId
|
||||
);
|
||||
initialState.activePath = pathToActive
|
||||
? pathToActive.slice(1)
|
||||
: [];
|
||||
|
||||
if (initialState.activePath.length >= 1) {
|
||||
const level1Category = initialState.activePath[0];
|
||||
initialState.level2Categories = level1Category.children || [];
|
||||
}
|
||||
|
||||
if (initialState.activePath.length >= 2) {
|
||||
const level2Category = initialState.activePath[1];
|
||||
initialState.level3Categories = level2Category.children || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error reading cache in constructor:", err);
|
||||
}
|
||||
|
||||
this.state = initialState;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchCategories();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected && !this.state.fetchedCategories) {
|
||||
// Socket just connected and we haven't fetched categories yet
|
||||
this.setState(
|
||||
{
|
||||
fetchedCategories: false,
|
||||
},
|
||||
() => {
|
||||
this.fetchCategories();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// If activeCategoryId changes, update subcategories
|
||||
if (
|
||||
prevProps.activeCategoryId !== this.props.activeCategoryId &&
|
||||
this.state.categoryTree
|
||||
) {
|
||||
this.processCategoryTree(this.state.categoryTree);
|
||||
}
|
||||
}
|
||||
|
||||
fetchCategories = () => {
|
||||
const { socket } = this.props;
|
||||
if (!socket || !socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch categories");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.fetchedCategories) {
|
||||
//console.log('Categories already fetched, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize global cache object if it doesn't exist
|
||||
// @note Handle both SSR (global.window) and browser (window) environments
|
||||
const windowObj = (typeof global !== "undefined" && global.window) ||
|
||||
(typeof window !== "undefined" && window);
|
||||
|
||||
if (windowObj && !windowObj.productCache) {
|
||||
windowObj.productCache = {};
|
||||
}
|
||||
|
||||
// Check if we have a valid cache in the global object
|
||||
try {
|
||||
const cacheKey = "categoryTree_209";
|
||||
const cachedData = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
|
||||
if (cachedData) {
|
||||
const { categoryTree, fetching } = cachedData;
|
||||
//const cacheAge = Date.now() - timestamp;
|
||||
//const tenMinutes = 10 * 60 * 1000; // 10 minutes in milliseconds
|
||||
|
||||
// If data is currently being fetched, wait for it
|
||||
if (fetching) {
|
||||
//console.log('CategoryList: Data is being fetched, waiting...');
|
||||
const checkInterval = setInterval(() => {
|
||||
const currentCache = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
|
||||
if (currentCache && !currentCache.fetching) {
|
||||
clearInterval(checkInterval);
|
||||
if (currentCache.categoryTree) {
|
||||
this.processCategoryTree(currentCache.categoryTree);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// If cache is less than 10 minutes old, use it
|
||||
if (/*cacheAge < tenMinutes &&*/ categoryTree) {
|
||||
//console.log('Using cached category tree, age:', Math.round(cacheAge/1000), 'seconds');
|
||||
// Defer processing to next tick to avoid blocking
|
||||
//setTimeout(() => {
|
||||
this.processCategoryTree(categoryTree);
|
||||
//}, 0);
|
||||
//return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error reading from cache:", err);
|
||||
}
|
||||
|
||||
// Mark as being fetched to prevent concurrent calls
|
||||
const cacheKey = "categoryTree_209";
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
fetching: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
this.setState({ fetchedCategories: true });
|
||||
|
||||
//console.log('CategoryList: Fetching categories from socket');
|
||||
socket.emit("categoryList", { categoryId: 209 }, (response) => {
|
||||
if (response && response.categoryTree) {
|
||||
//console.log('Category tree received:', response.categoryTree);
|
||||
|
||||
// Store in global cache with timestamp
|
||||
try {
|
||||
const cacheKey = "categoryTree_209";
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
categoryTree: response.categoryTree,
|
||||
timestamp: Date.now(),
|
||||
fetching: false,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error writing to cache:", err);
|
||||
}
|
||||
|
||||
this.processCategoryTree(response.categoryTree);
|
||||
} else {
|
||||
try {
|
||||
const cacheKey = "categoryTree_209";
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
categoryTree: null,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error writing to cache:", err);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
categoryTree: null,
|
||||
level1Categories: [],
|
||||
level2Categories: [],
|
||||
level3Categories: [],
|
||||
activePath: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
processCategoryTree = (categoryTree) => {
|
||||
// Level 1 categories are always the children of category 209 (Home)
|
||||
const level1Categories =
|
||||
categoryTree && categoryTree.id === 209
|
||||
? categoryTree.children || []
|
||||
: [];
|
||||
|
||||
// Build the navigation path and determine what to show at each level
|
||||
let level2Categories = [];
|
||||
let level3Categories = [];
|
||||
let activePath = [];
|
||||
|
||||
if (this.props.activeCategoryId) {
|
||||
const activeCategory = this.findCategoryById(
|
||||
categoryTree,
|
||||
this.props.activeCategoryId
|
||||
);
|
||||
if (activeCategory) {
|
||||
// Build the path from root to active category
|
||||
const pathToActive = this.getPathToCategory(
|
||||
categoryTree,
|
||||
this.props.activeCategoryId
|
||||
);
|
||||
activePath = pathToActive.slice(1); // Remove root (209) from path
|
||||
|
||||
// Determine what to show at each level based on the path depth
|
||||
if (activePath.length >= 1) {
|
||||
// Show children of the level 1 category
|
||||
const level1Category = activePath[0];
|
||||
level2Categories = level1Category.children || [];
|
||||
}
|
||||
|
||||
if (activePath.length >= 2) {
|
||||
// Show children of the level 2 category
|
||||
const level2Category = activePath[1];
|
||||
level3Categories = level2Category.children || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
categoryTree,
|
||||
level1Categories,
|
||||
level2Categories,
|
||||
level3Categories,
|
||||
activePath,
|
||||
fetchedCategories: true,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { level1Categories, level2Categories, level3Categories, activePath } =
|
||||
this.state;
|
||||
|
||||
const renderCategoryRow = (categories, level = 1) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
flexWrap: "nowrap",
|
||||
overflowX: "auto",
|
||||
py: 0.5, // Add vertical padding to prevent border clipping
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none",
|
||||
},
|
||||
scrollbarWidth: "none",
|
||||
msOverflowStyle: "none",
|
||||
}}
|
||||
>
|
||||
{level === 1 && (
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
color="inherit"
|
||||
size="small"
|
||||
aria-label="Zur Startseite"
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: "normal",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
mx: 0.5,
|
||||
my: 0.25, // Add consistent vertical margin to account for borders
|
||||
minWidth: "auto",
|
||||
border: "2px solid transparent", // Always have border space
|
||||
borderRadius: 1, // Always have border radius
|
||||
...(this.props.activeCategoryId === null && {
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
|
||||
transform: "translateY(-2px)",
|
||||
bgcolor: "rgba(255,255,255,0.25)",
|
||||
borderColor: "rgba(255,255,255,0.6)", // Change border color instead of adding border
|
||||
fontWeight: "bold",
|
||||
opacity: 1,
|
||||
}),
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
bgcolor: "rgba(255,255,255,0.15)",
|
||||
transform: "translateY(-1px)",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.3)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<HomeIcon sx={{ fontSize: "1rem" }} />
|
||||
</Button>
|
||||
)}
|
||||
{this.state.fetchedCategories && categories.length > 0 ? (
|
||||
<>
|
||||
{categories.map((category) => {
|
||||
// Determine if this category is active at this level
|
||||
const isActiveAtThisLevel =
|
||||
activePath[level - 1] &&
|
||||
activePath[level - 1].id === category.id;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
component={Link}
|
||||
to={`/Kategorie/${category.seoName}`}
|
||||
color="inherit"
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: "normal",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
mx: 0.5,
|
||||
my: 0.25, // Add consistent vertical margin to account for borders
|
||||
border: "2px solid transparent", // Always have border space
|
||||
borderRadius: 1, // Always have border radius
|
||||
...(isActiveAtThisLevel && {
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
|
||||
transform: "translateY(-2px)",
|
||||
bgcolor: "rgba(255,255,255,0.25)",
|
||||
borderColor: "rgba(255,255,255,0.6)", // Change border color instead of adding border
|
||||
fontWeight: "bold",
|
||||
opacity: 1,
|
||||
}),
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
bgcolor: "rgba(255,255,255,0.15)",
|
||||
transform: "translateY(-1px)",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.3)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
level === 1 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="inherit"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
height: "30px", // Match small button height
|
||||
px: 1,
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const onRenderCallback = (id, phase, actualDuration) => {
|
||||
if (actualDuration > 50) {
|
||||
console.warn(
|
||||
`CategoryList render took ${actualDuration}ms in ${phase} phase`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Profiler id="CategoryList" onRender={onRenderCallback}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
bgcolor: "primary.dark",
|
||||
display: { xs: "none", md: "block" },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg" sx={{ px: 2 }}>
|
||||
{/* Level 1 Categories Row - Always shown */}
|
||||
{renderCategoryRow(level1Categories, 1)}
|
||||
|
||||
{/* Level 2 Categories Row - Show when level 1 is selected */}
|
||||
{level2Categories.length > 0 && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{renderCategoryRow(level2Categories, 2)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Level 3 Categories Row - Show when level 2 is selected */}
|
||||
{level3Categories.length > 0 && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{renderCategoryRow(level3Categories, 3)}
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
</Profiler>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CategoryList;
|
||||
27
src/components/header/Logo.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const Logo = () => {
|
||||
return (
|
||||
<Box
|
||||
component={Link}
|
||||
to="/"
|
||||
aria-label="Zur Startseite"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/assets/images/sh.png"
|
||||
alt="SH Logo"
|
||||
style={{ height: "45px" }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
310
src/components/header/SearchBar.js
Normal file
@@ -0,0 +1,310 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
|
||||
const SearchBar = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const socket = React.useContext(SocketContext);
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
// State management
|
||||
const [searchQuery, setSearchQuery] = React.useState(
|
||||
searchParams.get("q") || ""
|
||||
);
|
||||
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);
|
||||
const autocompleteTimerRef = React.useRef(null);
|
||||
const isFirstKeystrokeRef = React.useRef(true);
|
||||
const inputRef = React.useRef(null);
|
||||
const suggestionBoxRef = React.useRef(null);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
delete window.currentSearchQuery;
|
||||
setShowSuggestions(false);
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSearchState = (value) => {
|
||||
setSearchQuery(value);
|
||||
|
||||
// Dispatch global custom event with search query value
|
||||
const searchEvent = new CustomEvent("search-query-change", {
|
||||
detail: { query: value },
|
||||
});
|
||||
// Store the current search query in the window object
|
||||
window.currentSearchQuery = value;
|
||||
window.dispatchEvent(searchEvent);
|
||||
};
|
||||
|
||||
// @note Autocomplete function using getSearchProducts Socket.io API - returns objects with name and seoName
|
||||
const fetchAutocomplete = React.useCallback(
|
||||
(query) => {
|
||||
if (!socket || !query || query.length < 2) {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
setLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSuggestions(true);
|
||||
|
||||
socket.emit(
|
||||
"getSearchProducts",
|
||||
{
|
||||
query: query.trim(),
|
||||
limit: 8,
|
||||
},
|
||||
(response) => {
|
||||
setLoadingSuggestions(false);
|
||||
|
||||
if (response && response.products) {
|
||||
// getSearchProducts returns response.products array
|
||||
const suggestions = response.products.slice(0, 8); // Limit to 8 suggestions
|
||||
setSuggestions(suggestions);
|
||||
setShowSuggestions(suggestions.length > 0);
|
||||
setSelectedIndex(-1); // Reset selection
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
console.log("getSearchProducts failed or no products:", response);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[socket]
|
||||
);
|
||||
|
||||
const handleSearchChange = (e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
// Always update the input field immediately for responsiveness
|
||||
setSearchQuery(value);
|
||||
|
||||
// Clear any existing timers
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (autocompleteTimerRef.current) {
|
||||
clearTimeout(autocompleteTimerRef.current);
|
||||
}
|
||||
|
||||
// Set the debounce timer for search state update
|
||||
const delay = isFirstKeystrokeRef.current ? 100 : 200;
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
updateSearchState(value);
|
||||
isFirstKeystrokeRef.current = false;
|
||||
|
||||
// Reset first keystroke flag after 1 second of inactivity
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
isFirstKeystrokeRef.current = true;
|
||||
}, 1000);
|
||||
}, delay);
|
||||
|
||||
// Set autocomplete timer with longer delay to reduce API calls
|
||||
autocompleteTimerRef.current = setTimeout(() => {
|
||||
fetchAutocomplete(value);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Handle keyboard navigation in suggestions
|
||||
const handleKeyDown = (e) => {
|
||||
if (!showSuggestions || suggestions.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < suggestions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||
const selectedSuggestion = suggestions[selectedIndex];
|
||||
setSearchQuery(selectedSuggestion.name);
|
||||
updateSearchState(selectedSuggestion.name);
|
||||
setShowSuggestions(false);
|
||||
navigate(`/Artikel/${selectedSuggestion.seoName}`);
|
||||
} else {
|
||||
handleSearch(e);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle suggestion click - navigate to product page directly
|
||||
const handleSuggestionClick = (suggestion) => {
|
||||
setSearchQuery(suggestion.name);
|
||||
updateSearchState(suggestion.name);
|
||||
setShowSuggestions(false);
|
||||
navigate(`/Artikel/${suggestion.seoName}`);
|
||||
};
|
||||
|
||||
// Handle input focus
|
||||
const handleFocus = () => {
|
||||
if (suggestions.length > 0 && searchQuery.length >= 2) {
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle input blur with delay to allow suggestion clicks
|
||||
const handleBlur = () => {
|
||||
setTimeout(() => {
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Clean up timers on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (autocompleteTimerRef.current) {
|
||||
clearTimeout(autocompleteTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Close suggestions when clicking outside
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (
|
||||
suggestionBoxRef.current &&
|
||||
!suggestionBoxRef.current.contains(event.target) &&
|
||||
!inputRef.current?.contains(event.target)
|
||||
) {
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleSearch}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
mx: { xs: 1, sm: 2, md: 4 },
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
ref={inputRef}
|
||||
placeholder="Produkte suchen..."
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: loadingSuggestions && (
|
||||
<InputAdornment position="end">
|
||||
<CircularProgress size={16} />
|
||||
</InputAdornment>
|
||||
),
|
||||
sx: { borderRadius: 2, bgcolor: "background.paper" },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Autocomplete Suggestions Dropdown */}
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<Paper
|
||||
ref={suggestionBoxRef}
|
||||
elevation={4}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1300,
|
||||
maxHeight: "300px",
|
||||
overflow: "auto",
|
||||
mt: 0.5,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<List disablePadding>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<ListItem
|
||||
key={suggestion.seoName || index}
|
||||
button
|
||||
selected={index === selectedIndex}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
backgroundColor: "action.hover",
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: "action.selected",
|
||||
"&:hover": {
|
||||
backgroundColor: "action.selected",
|
||||
},
|
||||
},
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" noWrap>
|
||||
{suggestion.name}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
4
src/components/header/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Logo } from './Logo.js';
|
||||
export { default as SearchBar } from './SearchBar.js';
|
||||
export { default as ButtonGroupWithRouter } from './ButtonGroup.js';
|
||||
export { default as CategoryList } from './CategoryList.js';
|
||||
138
src/components/profile/AddressForm.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import React from "react";
|
||||
import { Box, TextField, Typography } from "@mui/material";
|
||||
|
||||
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() === "";
|
||||
return isEmpty;
|
||||
};
|
||||
|
||||
// Helper function to get label styling for required fields
|
||||
const getRequiredFieldLabelSx = (fieldName, value) => {
|
||||
const showError = getRequiredFieldError(fieldName, value);
|
||||
return showError
|
||||
? {
|
||||
"&.MuiInputLabel-shrink": {
|
||||
color: "#d32f2f", // Material-UI error color
|
||||
},
|
||||
}
|
||||
: {};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Vorname"
|
||||
name="firstName"
|
||||
value={address.firstName}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}FirstName`]}
|
||||
helperText={errors[`${namePrefix}FirstName`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("firstName", address.firstName),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Nachname"
|
||||
name="lastName"
|
||||
value={address.lastName}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}LastName`]}
|
||||
helperText={errors[`${namePrefix}LastName`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("lastName", address.lastName),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Adresszusatz"
|
||||
name="addressAddition"
|
||||
value={address.addressAddition || ""}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
label="Straße"
|
||||
name="street"
|
||||
value={address.street}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}Street`]}
|
||||
helperText={errors[`${namePrefix}Street`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("street", address.street),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Hausnummer"
|
||||
name="houseNumber"
|
||||
value={address.houseNumber}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}HouseNumber`]}
|
||||
helperText={errors[`${namePrefix}HouseNumber`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("houseNumber", address.houseNumber),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="PLZ"
|
||||
name="postalCode"
|
||||
value={address.postalCode}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}PostalCode`]}
|
||||
helperText={errors[`${namePrefix}PostalCode`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("postalCode", address.postalCode),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Stadt"
|
||||
name="city"
|
||||
value={address.city}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}City`]}
|
||||
helperText={errors[`${namePrefix}City`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("city", address.city),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Land"
|
||||
name="country"
|
||||
value={address.country}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
disabled
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddressForm;
|
||||
510
src/components/profile/CartTab.js
Normal file
@@ -0,0 +1,510 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Typography, Button } from "@mui/material";
|
||||
import CartDropdown from "../CartDropdown.js";
|
||||
import CheckoutForm from "./CheckoutForm.js";
|
||||
import PaymentConfirmationDialog from "./PaymentConfirmationDialog.js";
|
||||
import OrderProcessingService from "./OrderProcessingService.js";
|
||||
import CheckoutValidation from "./CheckoutValidation.js";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
|
||||
class CartTab extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const initialCartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
const initialDeliveryMethod = CheckoutValidation.shouldForcePickupDelivery(initialCartItems) ? "Abholung" : "DHL";
|
||||
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(initialDeliveryMethod, initialCartItems, 0);
|
||||
|
||||
this.state = {
|
||||
isCheckingOut: false,
|
||||
cartItems: initialCartItems,
|
||||
deliveryMethod: initialDeliveryMethod,
|
||||
paymentMethod: optimalPaymentMethod,
|
||||
invoiceAddress: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
addressAddition: "",
|
||||
street: "",
|
||||
houseNumber: "",
|
||||
postalCode: "",
|
||||
city: "",
|
||||
country: "Deutschland",
|
||||
},
|
||||
deliveryAddress: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
addressAddition: "",
|
||||
street: "",
|
||||
houseNumber: "",
|
||||
postalCode: "",
|
||||
city: "",
|
||||
country: "Deutschland",
|
||||
},
|
||||
useSameAddress: true,
|
||||
saveAddressForFuture: true,
|
||||
addressFormErrors: {},
|
||||
termsAccepted: false,
|
||||
isCompletingOrder: false,
|
||||
completionError: null,
|
||||
note: "",
|
||||
stripeClientSecret: null,
|
||||
showStripePayment: false,
|
||||
StripeComponent: null,
|
||||
isLoadingStripe: false,
|
||||
showPaymentConfirmation: false,
|
||||
orderCompleted: false,
|
||||
originalCartItems: []
|
||||
};
|
||||
|
||||
// Initialize order processing service
|
||||
this.orderService = new OrderProcessingService(
|
||||
() => this.context,
|
||||
this.setState.bind(this)
|
||||
);
|
||||
this.orderService.getState = () => this.state;
|
||||
this.orderService.setOrderSuccessCallback(this.props.onOrderSuccess);
|
||||
}
|
||||
|
||||
// @note Add method to fetch and apply order template prefill data
|
||||
fetchOrderTemplate = () => {
|
||||
if (this.context && this.context.connected) {
|
||||
this.context.emit('getOrderTemplate', (response) => {
|
||||
if (response.success && response.orderTemplate) {
|
||||
const template = response.orderTemplate;
|
||||
|
||||
// Map the template fields to our state structure
|
||||
const invoiceAddress = {
|
||||
firstName: template.invoice_address_name ? template.invoice_address_name.split(' ')[0] || "" : "",
|
||||
lastName: template.invoice_address_name ? template.invoice_address_name.split(' ').slice(1).join(' ') || "" : "",
|
||||
addressAddition: template.invoice_address_line2 || "",
|
||||
street: template.invoice_address_street || "",
|
||||
houseNumber: template.invoice_address_house_number || "",
|
||||
postalCode: template.invoice_address_postal_code || "",
|
||||
city: template.invoice_address_city || "",
|
||||
country: template.invoice_address_country || "Deutschland",
|
||||
};
|
||||
|
||||
const deliveryAddress = {
|
||||
firstName: template.shipping_address_name ? template.shipping_address_name.split(' ')[0] || "" : "",
|
||||
lastName: template.shipping_address_name ? template.shipping_address_name.split(' ').slice(1).join(' ') || "" : "",
|
||||
addressAddition: template.shipping_address_line2 || "",
|
||||
street: template.shipping_address_street || "",
|
||||
houseNumber: template.shipping_address_house_number || "",
|
||||
postalCode: template.shipping_address_postal_code || "",
|
||||
city: template.shipping_address_city || "",
|
||||
country: template.shipping_address_country || "Deutschland",
|
||||
};
|
||||
|
||||
// Get current cart state to check constraints
|
||||
const currentCartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(currentCartItems);
|
||||
|
||||
// Determine delivery method - respect cart constraints
|
||||
let prefillDeliveryMethod = template.delivery_method || "DHL";
|
||||
if (isPickupOnly || hasStecklinge) {
|
||||
prefillDeliveryMethod = "Abholung";
|
||||
}
|
||||
|
||||
// Map delivery method values if needed
|
||||
const deliveryMethodMap = {
|
||||
"standard": "DHL",
|
||||
"express": "DPD",
|
||||
"pickup": "Abholung"
|
||||
};
|
||||
prefillDeliveryMethod = deliveryMethodMap[prefillDeliveryMethod] || prefillDeliveryMethod;
|
||||
|
||||
// Determine payment method - respect constraints
|
||||
let prefillPaymentMethod = template.payment_method || "wire";
|
||||
const paymentMethodMap = {
|
||||
"credit_card": "stripe",
|
||||
"bank_transfer": "wire",
|
||||
"cash_on_delivery": "onDelivery",
|
||||
"cash": "cash"
|
||||
};
|
||||
prefillPaymentMethod = paymentMethodMap[prefillPaymentMethod] || prefillPaymentMethod;
|
||||
|
||||
// Validate payment method against delivery method constraints
|
||||
prefillPaymentMethod = CheckoutValidation.validatePaymentMethodForDelivery(
|
||||
prefillDeliveryMethod,
|
||||
prefillPaymentMethod,
|
||||
currentCartItems,
|
||||
0 // Use 0 for delivery cost during prefill
|
||||
);
|
||||
|
||||
// Apply prefill data to state
|
||||
this.setState({
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
deliveryMethod: prefillDeliveryMethod,
|
||||
paymentMethod: prefillPaymentMethod,
|
||||
saveAddressForFuture: template.save_address_for_future === 1,
|
||||
useSameAddress: true // Default to same address, user can change if needed
|
||||
});
|
||||
|
||||
console.log("Order template applied successfully");
|
||||
} else {
|
||||
console.log("No order template available or failed to fetch");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// Handle payment completion if detected
|
||||
if (this.props.paymentCompletion) {
|
||||
this.orderService.handlePaymentCompletion(
|
||||
this.props.paymentCompletion,
|
||||
this.props.onClearPaymentCompletion
|
||||
);
|
||||
}
|
||||
|
||||
// @note Fetch order template for prefill when component mounts
|
||||
this.fetchOrderTemplate();
|
||||
|
||||
this.cart = () => {
|
||||
// @note Don't update cart if we're showing payment confirmation - keep it empty
|
||||
if (this.state.showPaymentConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
const shouldForcePickup = CheckoutValidation.shouldForcePickupDelivery(cartItems);
|
||||
|
||||
const newDeliveryMethod = shouldForcePickup ? "Abholung" : this.state.deliveryMethod;
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
|
||||
// Get optimal payment method for the current state
|
||||
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(
|
||||
newDeliveryMethod,
|
||||
cartItems,
|
||||
deliveryCost
|
||||
);
|
||||
|
||||
// Use optimal payment method if current one is invalid, otherwise keep current
|
||||
const validatedPaymentMethod = CheckoutValidation.validatePaymentMethodForDelivery(
|
||||
newDeliveryMethod,
|
||||
this.state.paymentMethod,
|
||||
cartItems,
|
||||
deliveryCost
|
||||
);
|
||||
|
||||
const newPaymentMethod = validatedPaymentMethod !== this.state.paymentMethod
|
||||
? optimalPaymentMethod
|
||||
: this.state.paymentMethod;
|
||||
|
||||
this.setState({
|
||||
cartItems,
|
||||
deliveryMethod: newDeliveryMethod,
|
||||
paymentMethod: newPaymentMethod,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("cart", this.cart);
|
||||
this.cart(); // Initial check
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("cart", this.cart);
|
||||
this.orderService.cleanup();
|
||||
}
|
||||
|
||||
handleCheckout = () => {
|
||||
this.setState({ isCheckingOut: true });
|
||||
};
|
||||
|
||||
handleContinueShopping = () => {
|
||||
this.setState({ isCheckingOut: false });
|
||||
};
|
||||
|
||||
handleDeliveryMethodChange = (event) => {
|
||||
const newDeliveryMethod = event.target.value;
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
|
||||
// Get optimal payment method for the new delivery method
|
||||
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(
|
||||
newDeliveryMethod,
|
||||
this.state.cartItems,
|
||||
deliveryCost
|
||||
);
|
||||
|
||||
// Use optimal payment method if current one becomes invalid, otherwise keep current
|
||||
const validatedPaymentMethod = CheckoutValidation.validatePaymentMethodForDelivery(
|
||||
newDeliveryMethod,
|
||||
this.state.paymentMethod,
|
||||
this.state.cartItems,
|
||||
deliveryCost
|
||||
);
|
||||
|
||||
const newPaymentMethod = validatedPaymentMethod !== this.state.paymentMethod
|
||||
? optimalPaymentMethod
|
||||
: this.state.paymentMethod;
|
||||
|
||||
this.setState({
|
||||
deliveryMethod: newDeliveryMethod,
|
||||
paymentMethod: newPaymentMethod,
|
||||
});
|
||||
};
|
||||
|
||||
handlePaymentMethodChange = (event) => {
|
||||
this.setState({ paymentMethod: event.target.value });
|
||||
};
|
||||
|
||||
handleInvoiceAddressChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
this.setState((prevState) => ({
|
||||
invoiceAddress: {
|
||||
...prevState.invoiceAddress,
|
||||
[name]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
handleDeliveryAddressChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
this.setState((prevState) => ({
|
||||
deliveryAddress: {
|
||||
...prevState.deliveryAddress,
|
||||
[name]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
handleUseSameAddressChange = (e) => {
|
||||
const useSameAddress = e.target.checked;
|
||||
this.setState({
|
||||
useSameAddress,
|
||||
deliveryAddress: useSameAddress
|
||||
? this.state.invoiceAddress
|
||||
: this.state.deliveryAddress,
|
||||
});
|
||||
};
|
||||
|
||||
handleTermsAcceptedChange = (e) => {
|
||||
this.setState({ termsAccepted: e.target.checked });
|
||||
};
|
||||
|
||||
handleNoteChange = (e) => {
|
||||
this.setState({ note: e.target.value });
|
||||
};
|
||||
|
||||
handleSaveAddressForFutureChange = (e) => {
|
||||
this.setState({ saveAddressForFuture: e.target.checked });
|
||||
};
|
||||
|
||||
validateAddressForm = () => {
|
||||
const errors = CheckoutValidation.validateAddressForm(this.state);
|
||||
this.setState({ addressFormErrors: errors });
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
loadStripeComponent = async (clientSecret) => {
|
||||
this.setState({ isLoadingStripe: true });
|
||||
|
||||
try {
|
||||
const { default: Stripe } = await import("../Stripe.js");
|
||||
this.setState({
|
||||
StripeComponent: Stripe,
|
||||
stripeClientSecret: clientSecret,
|
||||
showStripePayment: true,
|
||||
isCompletingOrder: false,
|
||||
isLoadingStripe: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load Stripe component:", error);
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
isLoadingStripe: false,
|
||||
completionError: "Failed to load payment component. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleCompleteOrder = () => {
|
||||
this.setState({ completionError: null }); // Clear previous errors
|
||||
|
||||
const validationError = CheckoutValidation.getValidationErrorMessage(this.state);
|
||||
if (validationError) {
|
||||
this.setState({ completionError: validationError });
|
||||
this.validateAddressForm(); // To show field-specific errors
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isCompletingOrder: true });
|
||||
|
||||
const {
|
||||
deliveryMethod,
|
||||
paymentMethod,
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
cartItems,
|
||||
note,
|
||||
saveAddressForFuture,
|
||||
} = this.state;
|
||||
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
|
||||
// Handle Stripe payment differently
|
||||
if (paymentMethod === "stripe") {
|
||||
// Store the cart items used for Stripe payment in sessionStorage for later reference
|
||||
try {
|
||||
sessionStorage.setItem('stripePaymentCart', JSON.stringify(cartItems));
|
||||
} catch (error) {
|
||||
console.error("Failed to store Stripe payment cart:", error);
|
||||
}
|
||||
|
||||
// Calculate total amount for Stripe
|
||||
const subtotal = cartItems.reduce(
|
||||
(total, item) => total + item.price * item.quantity,
|
||||
0
|
||||
);
|
||||
const totalAmount = Math.round((subtotal + deliveryCost) * 100); // Convert to cents
|
||||
|
||||
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle regular orders
|
||||
const orderData = {
|
||||
items: cartItems,
|
||||
invoiceAddress,
|
||||
deliveryAddress: useSameAddress ? invoiceAddress : deliveryAddress,
|
||||
deliveryMethod,
|
||||
paymentMethod,
|
||||
deliveryCost,
|
||||
note,
|
||||
domain: window.location.origin,
|
||||
saveAddressForFuture,
|
||||
};
|
||||
|
||||
this.orderService.processRegularOrder(orderData);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
cartItems,
|
||||
deliveryMethod,
|
||||
paymentMethod,
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
saveAddressForFuture,
|
||||
addressFormErrors,
|
||||
termsAccepted,
|
||||
isCompletingOrder,
|
||||
completionError,
|
||||
note,
|
||||
stripeClientSecret,
|
||||
showStripePayment,
|
||||
StripeComponent,
|
||||
isLoadingStripe,
|
||||
showPaymentConfirmation,
|
||||
orderCompleted,
|
||||
} = this.state;
|
||||
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(cartItems);
|
||||
|
||||
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state);
|
||||
const displayError = completionError || preSubmitError;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Payment Confirmation */}
|
||||
{showPaymentConfirmation && (
|
||||
<PaymentConfirmationDialog
|
||||
paymentCompletionData={this.orderService.paymentCompletionData}
|
||||
isCompletingOrder={isCompletingOrder}
|
||||
completionError={completionError}
|
||||
orderCompleted={orderCompleted}
|
||||
onContinueShopping={() => {
|
||||
this.setState({ showPaymentConfirmation: false });
|
||||
}}
|
||||
onViewOrders={() => {
|
||||
if (this.props.onOrderSuccess) {
|
||||
this.props.onOrderSuccess();
|
||||
}
|
||||
this.setState({ showPaymentConfirmation: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* @note Hide CartDropdown when showing payment confirmation */}
|
||||
{!showPaymentConfirmation && (
|
||||
<CartDropdown
|
||||
cartItems={cartItems}
|
||||
socket={this.context}
|
||||
showDetailedSummary={showStripePayment}
|
||||
deliveryMethod={deliveryMethod}
|
||||
deliveryCost={deliveryCost}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cartItems.length > 0 && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{isLoadingStripe ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body1">
|
||||
Zahlungskomponente wird geladen...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : showStripePayment && StripeComponent ? (
|
||||
<>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => this.setState({ showStripePayment: false, stripeClientSecret: null })}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
borderColor: '#2e7d32',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(46, 125, 50, 0.04)',
|
||||
borderColor: '#1b5e20'
|
||||
}
|
||||
}}
|
||||
>
|
||||
← Zurück zur Bestellung
|
||||
</Button>
|
||||
</Box>
|
||||
<StripeComponent clientSecret={stripeClientSecret} />
|
||||
</>
|
||||
) : (
|
||||
<CheckoutForm
|
||||
paymentMethod={paymentMethod}
|
||||
invoiceAddress={invoiceAddress}
|
||||
deliveryAddress={deliveryAddress}
|
||||
useSameAddress={useSameAddress}
|
||||
saveAddressForFuture={saveAddressForFuture}
|
||||
addressFormErrors={addressFormErrors}
|
||||
termsAccepted={termsAccepted}
|
||||
note={note}
|
||||
deliveryMethod={deliveryMethod}
|
||||
hasStecklinge={hasStecklinge}
|
||||
isPickupOnly={isPickupOnly}
|
||||
deliveryCost={deliveryCost}
|
||||
cartItems={cartItems}
|
||||
displayError={displayError}
|
||||
isCompletingOrder={isCompletingOrder}
|
||||
preSubmitError={preSubmitError}
|
||||
onInvoiceAddressChange={this.handleInvoiceAddressChange}
|
||||
onDeliveryAddressChange={this.handleDeliveryAddressChange}
|
||||
onUseSameAddressChange={this.handleUseSameAddressChange}
|
||||
onSaveAddressForFutureChange={this.handleSaveAddressForFutureChange}
|
||||
onTermsAcceptedChange={this.handleTermsAcceptedChange}
|
||||
onNoteChange={this.handleNoteChange}
|
||||
onDeliveryMethodChange={this.handleDeliveryMethodChange}
|
||||
onPaymentMethodChange={this.handlePaymentMethodChange}
|
||||
onCompleteOrder={this.handleCompleteOrder}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set static contextType to access the socket
|
||||
CartTab.contextType = SocketContext;
|
||||
|
||||
export default CartTab;
|
||||
185
src/components/profile/CheckoutForm.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Typography, TextField, Checkbox, FormControlLabel, Button } from "@mui/material";
|
||||
import AddressForm from "./AddressForm.js";
|
||||
import DeliveryMethodSelector from "./DeliveryMethodSelector.js";
|
||||
import PaymentMethodSelector from "./PaymentMethodSelector.js";
|
||||
import OrderSummary from "./OrderSummary.js";
|
||||
|
||||
class CheckoutForm extends Component {
|
||||
render() {
|
||||
const {
|
||||
paymentMethod,
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
saveAddressForFuture,
|
||||
addressFormErrors,
|
||||
termsAccepted,
|
||||
note,
|
||||
deliveryMethod,
|
||||
hasStecklinge,
|
||||
isPickupOnly,
|
||||
deliveryCost,
|
||||
cartItems,
|
||||
displayError,
|
||||
isCompletingOrder,
|
||||
preSubmitError,
|
||||
onInvoiceAddressChange,
|
||||
onDeliveryAddressChange,
|
||||
onUseSameAddressChange,
|
||||
onSaveAddressForFutureChange,
|
||||
onTermsAcceptedChange,
|
||||
onNoteChange,
|
||||
onDeliveryMethodChange,
|
||||
onPaymentMethodChange,
|
||||
onCompleteOrder,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{paymentMethod !== "cash" && (
|
||||
<>
|
||||
<AddressForm
|
||||
title="Rechnungsadresse"
|
||||
address={invoiceAddress}
|
||||
onChange={onInvoiceAddressChange}
|
||||
errors={addressFormErrors}
|
||||
namePrefix="invoice"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={saveAddressForFuture}
|
||||
onChange={onSaveAddressForFutureChange}
|
||||
sx={{ '& .MuiSvgIcon-root': { fontSize: 28 } }}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
Für zukünftige Bestellungen speichern
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasStecklinge && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ mb: 2, fontWeight: "bold", color: "#2e7d32" }}
|
||||
>
|
||||
Für welchen Termin ist die Abholung der Stecklinge
|
||||
gewünscht?
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Anmerkung"
|
||||
name="note"
|
||||
value={note}
|
||||
onChange={onNoteChange}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
sx={{ mb: 2 }}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
|
||||
<DeliveryMethodSelector
|
||||
deliveryMethod={deliveryMethod}
|
||||
onChange={onDeliveryMethodChange}
|
||||
isPickupOnly={isPickupOnly || hasStecklinge}
|
||||
/>
|
||||
|
||||
{(deliveryMethod === "DHL" || deliveryMethod === "DPD") && (
|
||||
<>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={useSameAddress}
|
||||
onChange={onUseSameAddressChange}
|
||||
sx={{ '& .MuiSvgIcon-root': { fontSize: 28 } }}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body1">
|
||||
Lieferadresse ist identisch mit Rechnungsadresse
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{!useSameAddress && (
|
||||
<AddressForm
|
||||
title="Lieferadresse"
|
||||
address={deliveryAddress}
|
||||
onChange={onDeliveryAddressChange}
|
||||
errors={addressFormErrors}
|
||||
namePrefix="delivery"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<PaymentMethodSelector
|
||||
paymentMethod={paymentMethod}
|
||||
onChange={onPaymentMethodChange}
|
||||
deliveryMethod={deliveryMethod}
|
||||
onDeliveryMethodChange={onDeliveryMethodChange}
|
||||
cartItems={cartItems}
|
||||
deliveryCost={deliveryCost}
|
||||
/>
|
||||
|
||||
<OrderSummary deliveryCost={deliveryCost} cartItems={cartItems} />
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={termsAccepted}
|
||||
onChange={onTermsAcceptedChange}
|
||||
sx={{
|
||||
'& .MuiSvgIcon-root': { fontSize: 28 },
|
||||
alignSelf: 'flex-start',
|
||||
mt: -0.5
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
Ich habe die AGBs, die Datenschutzerklärung und die
|
||||
Bestimmungen zum Widerrufsrecht gelesen
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 3, mt: 2 }}
|
||||
/>
|
||||
|
||||
{/* @note Reserve space for error message to prevent layout shift */}
|
||||
<Box sx={{ minHeight: '24px', mb: 2, textAlign: "center" }}>
|
||||
{displayError && (
|
||||
<Typography color="error" sx={{ lineHeight: '24px' }}>
|
||||
{displayError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
sx={{ bgcolor: "#2e7d32", "&:hover": { bgcolor: "#1b5e20" } }}
|
||||
onClick={onCompleteOrder}
|
||||
disabled={isCompletingOrder || !!preSubmitError}
|
||||
>
|
||||
{isCompletingOrder
|
||||
? "Bestellung wird verarbeitet..."
|
||||
: "Bestellung abschließen"}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CheckoutForm;
|
||||
150
src/components/profile/CheckoutValidation.js
Normal file
@@ -0,0 +1,150 @@
|
||||
class CheckoutValidation {
|
||||
static validateAddressForm(state) {
|
||||
const {
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
deliveryMethod,
|
||||
paymentMethod,
|
||||
} = state;
|
||||
const errors = {};
|
||||
|
||||
// Validate invoice address (skip if payment method is "cash")
|
||||
if (paymentMethod !== "cash") {
|
||||
if (!invoiceAddress.firstName)
|
||||
errors.invoiceFirstName = "Vorname erforderlich";
|
||||
if (!invoiceAddress.lastName)
|
||||
errors.invoiceLastName = "Nachname erforderlich";
|
||||
if (!invoiceAddress.street) errors.invoiceStreet = "Straße erforderlich";
|
||||
if (!invoiceAddress.houseNumber)
|
||||
errors.invoiceHouseNumber = "Hausnummer erforderlich";
|
||||
if (!invoiceAddress.postalCode)
|
||||
errors.invoicePostalCode = "PLZ erforderlich";
|
||||
if (!invoiceAddress.city) errors.invoiceCity = "Stadt erforderlich";
|
||||
}
|
||||
|
||||
// Validate delivery address for shipping methods that require it
|
||||
if (
|
||||
!useSameAddress &&
|
||||
(deliveryMethod === "DHL" || deliveryMethod === "DPD")
|
||||
) {
|
||||
if (!deliveryAddress.firstName)
|
||||
errors.deliveryFirstName = "Vorname erforderlich";
|
||||
if (!deliveryAddress.lastName)
|
||||
errors.deliveryLastName = "Nachname erforderlich";
|
||||
if (!deliveryAddress.street)
|
||||
errors.deliveryStreet = "Straße erforderlich";
|
||||
if (!deliveryAddress.houseNumber)
|
||||
errors.deliveryHouseNumber = "Hausnummer erforderlich";
|
||||
if (!deliveryAddress.postalCode)
|
||||
errors.deliveryPostalCode = "PLZ erforderlich";
|
||||
if (!deliveryAddress.city) errors.deliveryCity = "Stadt erforderlich";
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
static getValidationErrorMessage(state, isAddressOnly = false) {
|
||||
const { termsAccepted } = state;
|
||||
|
||||
const addressErrors = this.validateAddressForm(state);
|
||||
|
||||
if (isAddressOnly) {
|
||||
return addressErrors;
|
||||
}
|
||||
|
||||
if (Object.keys(addressErrors).length > 0) {
|
||||
return "Bitte überprüfen Sie Ihre Eingaben in den Adressfeldern.";
|
||||
}
|
||||
|
||||
// Validate terms acceptance
|
||||
if (!termsAccepted) {
|
||||
return "Bitte akzeptieren Sie die AGBs, Datenschutzerklärung und Widerrufsrecht, um fortzufahren.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static getOptimalPaymentMethod(deliveryMethod, cartItems = [], deliveryCost = 0) {
|
||||
// Calculate total amount
|
||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||
const totalAmount = subtotal + deliveryCost;
|
||||
|
||||
// If total is 0, only cash is allowed
|
||||
if (totalAmount === 0) {
|
||||
return "cash";
|
||||
}
|
||||
|
||||
// If total is less than 0.50€, stripe is not available
|
||||
if (totalAmount < 0.50) {
|
||||
return "wire";
|
||||
}
|
||||
|
||||
// Prefer stripe when available and meets minimum amount
|
||||
if (deliveryMethod === "DHL" || deliveryMethod === "DPD" || deliveryMethod === "Abholung") {
|
||||
return "stripe";
|
||||
}
|
||||
|
||||
// Fall back to wire transfer
|
||||
return "wire";
|
||||
}
|
||||
|
||||
static validatePaymentMethodForDelivery(deliveryMethod, paymentMethod, cartItems = [], deliveryCost = 0) {
|
||||
let newPaymentMethod = paymentMethod;
|
||||
|
||||
// Calculate total amount for minimum validation
|
||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||
const totalAmount = subtotal + deliveryCost;
|
||||
|
||||
// Reset payment method if it's no longer valid
|
||||
if (deliveryMethod !== "DHL" && paymentMethod === "onDelivery") {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
// Allow stripe for DHL, DPD, and Abholung delivery methods, but check minimum amount
|
||||
if (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung" && paymentMethod === "stripe") {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
// Check minimum amount for stripe payments
|
||||
if (paymentMethod === "stripe" && totalAmount < 0.50) {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
if (deliveryMethod !== "Abholung" && paymentMethod === "cash") {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
return newPaymentMethod;
|
||||
}
|
||||
|
||||
static shouldForcePickupDelivery(cartItems) {
|
||||
const isPickupOnly = cartItems.some(
|
||||
(item) => item.versandklasse === "nur Abholung"
|
||||
);
|
||||
const hasStecklinge = cartItems.some(
|
||||
(item) =>
|
||||
item.id &&
|
||||
typeof item.id === "string" &&
|
||||
item.id.endsWith("steckling")
|
||||
);
|
||||
|
||||
return isPickupOnly || hasStecklinge;
|
||||
}
|
||||
|
||||
static getCartItemFlags(cartItems) {
|
||||
const isPickupOnly = cartItems.some(
|
||||
(item) => item.versandklasse === "nur Abholung"
|
||||
);
|
||||
const hasStecklinge = cartItems.some(
|
||||
(item) =>
|
||||
item.id &&
|
||||
typeof item.id === "string" &&
|
||||
item.id.endsWith("steckling")
|
||||
);
|
||||
|
||||
return { isPickupOnly, hasStecklinge };
|
||||
}
|
||||
}
|
||||
|
||||
export default CheckoutValidation;
|
||||
122
src/components/profile/DeliveryMethodSelector.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
|
||||
const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly }) => {
|
||||
const deliveryOptions = [
|
||||
{
|
||||
id: 'DHL',
|
||||
name: 'DHL',
|
||||
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
|
||||
price: '6,99 €',
|
||||
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: 'Sperrgut',
|
||||
description: 'Für große und schwere Artikel',
|
||||
price: '28,99 €',
|
||||
disabled: true,
|
||||
isCheckbox: true
|
||||
},
|
||||
{
|
||||
id: 'Abholung',
|
||||
name: 'Abholung in der Filiale',
|
||||
description: '',
|
||||
price: ''
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Versandart wählen
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{deliveryOptions.map((option, index) => (
|
||||
<Box
|
||||
key={option.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: index < deliveryOptions.length - 1 ? 1 : 0,
|
||||
p: 1,
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 1,
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
backgroundColor: option.disabled ? '#f5f5f5' : 'transparent',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': !option.disabled ? {
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderColor: '#2e7d32',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
} : {},
|
||||
...(deliveryMethod === option.id && !option.disabled && {
|
||||
backgroundColor: '#e8f5e8',
|
||||
borderColor: '#2e7d32'
|
||||
})
|
||||
}}
|
||||
onClick={!option.disabled && !option.isCheckbox ? () => onChange({ target: { value: option.id } }) : undefined}
|
||||
>
|
||||
{option.isCheckbox ? (
|
||||
<Checkbox
|
||||
id={option.id}
|
||||
disabled={option.disabled}
|
||||
checked={false}
|
||||
sx={{ color: 'rgba(0, 0, 0, 0.54)' }}
|
||||
/>
|
||||
) : (
|
||||
<Radio
|
||||
id={option.id}
|
||||
name="deliveryMethod"
|
||||
value={option.id}
|
||||
checked={deliveryMethod === option.id}
|
||||
onChange={onChange}
|
||||
disabled={option.disabled}
|
||||
sx={{ cursor: option.disabled ? 'not-allowed' : 'pointer' }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ ml: 2, flexGrow: 1 }}>
|
||||
<label
|
||||
htmlFor={option.id}
|
||||
style={{
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
color: option.disabled ? 'rgba(0, 0, 0, 0.54)' : 'inherit'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ color: 'inherit' }}>
|
||||
{option.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ color: 'inherit' }}
|
||||
>
|
||||
{option.description}
|
||||
</Typography>
|
||||
</label>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: option.disabled ? 'rgba(0, 0, 0, 0.54)' : 'inherit' }}
|
||||
>
|
||||
{option.price}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeliveryMethodSelector;
|
||||
171
src/components/profile/OrderDetailsDialog.js
Normal file
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
|
||||
const OrderDetailsDialog = ({ open, onClose, order }) => {
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||
|
||||
const handleCancelOrder = () => {
|
||||
// Implement order cancellation logic here
|
||||
console.log(`Cancel order: ${order.orderId}`);
|
||||
onClose(); // Close the dialog after action
|
||||
};
|
||||
|
||||
const subtotal = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
|
||||
const total = subtotal + order.delivery_cost;
|
||||
|
||||
// Calculate VAT breakdown similar to CartDropdown
|
||||
const vatCalculations = order.items.reduce((acc, item) => {
|
||||
const totalItemPrice = item.price * item.quantity_ordered;
|
||||
const netPrice = totalItemPrice / (1 + item.vat / 100);
|
||||
const vatAmount = totalItemPrice - netPrice;
|
||||
|
||||
acc.totalGross += totalItemPrice;
|
||||
acc.totalNet += netPrice;
|
||||
|
||||
if (item.vat === 7) {
|
||||
acc.vat7 += vatAmount;
|
||||
} else if (item.vat === 19) {
|
||||
acc.vat19 += vatAmount;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Bestelldetails: {order.orderId}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<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>
|
||||
<Typography>{order.shipping_address_country}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<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>
|
||||
<Typography>{order.invoice_address_country}</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Order Details Section */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Bestelldetails</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 4 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">Lieferart:</Typography>
|
||||
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || 'Nicht angegeben'}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<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>Bestellte Artikel</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Artikel</TableCell>
|
||||
<TableCell align="right">Menge</TableCell>
|
||||
<TableCell align="right">Preis</TableCell>
|
||||
<TableCell align="right">Gesamt</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{order.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell align="right">{item.quantity_ordered}</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(item.price)}</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(item.price * item.quantity_ordered)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">Gesamtnettopreis</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">{currencyFormatter.format(vatCalculations.totalNet)}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{vatCalculations.vat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">7% Mehrwertsteuer</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat7)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{vatCalculations.vat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">19% Mehrwertsteuer</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat19)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">Zwischensumme</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">{currencyFormatter.format(subtotal)}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">Lieferkosten</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(order.delivery_cost)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">Gesamtsumme</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">{currencyFormatter.format(total)}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{order.status === 'new' && (
|
||||
<Button onClick={handleCancelOrder} color="error">
|
||||
Bestellung stornieren
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Schließen</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderDetailsDialog;
|
||||
315
src/components/profile/OrderProcessingService.js
Normal file
@@ -0,0 +1,315 @@
|
||||
import { isUserLoggedIn } from "../LoginComponent.js";
|
||||
|
||||
class OrderProcessingService {
|
||||
constructor(getContext, setState) {
|
||||
this.getContext = getContext;
|
||||
this.setState = setState;
|
||||
this.verifyTokenHandler = null;
|
||||
this.verifyTokenTimeout = null;
|
||||
this.socketHandler = null;
|
||||
this.paymentCompletionData = null;
|
||||
}
|
||||
|
||||
// Clean up all event listeners and timeouts
|
||||
cleanup() {
|
||||
if (this.verifyTokenHandler) {
|
||||
window.removeEventListener('cart', this.verifyTokenHandler);
|
||||
this.verifyTokenHandler = null;
|
||||
}
|
||||
if (this.verifyTokenTimeout) {
|
||||
clearTimeout(this.verifyTokenTimeout);
|
||||
this.verifyTokenTimeout = null;
|
||||
}
|
||||
if (this.socketHandler) {
|
||||
window.removeEventListener('cart', this.socketHandler);
|
||||
this.socketHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle payment completion from parent component
|
||||
handlePaymentCompletion(paymentCompletion, onClearPaymentCompletion) {
|
||||
// Store payment completion data before clearing
|
||||
this.paymentCompletionData = { ...paymentCompletion };
|
||||
|
||||
// Clear payment completion data to prevent duplicates
|
||||
if (onClearPaymentCompletion) {
|
||||
onClearPaymentCompletion();
|
||||
}
|
||||
|
||||
// Show payment confirmation immediately but wait for verifyToken to complete
|
||||
this.setState({
|
||||
showPaymentConfirmation: true,
|
||||
cartItems: [] // Clear UI cart immediately
|
||||
});
|
||||
|
||||
// Wait for verifyToken to complete and populate window.cart, then process order
|
||||
this.waitForVerifyTokenAndProcessOrder();
|
||||
}
|
||||
|
||||
waitForVerifyTokenAndProcessOrder() {
|
||||
// Check if window.cart is already populated (verifyToken already completed)
|
||||
if (Array.isArray(window.cart) && window.cart.length > 0) {
|
||||
this.processStripeOrderWithCart(window.cart);
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for cart event which is dispatched after verifyToken completes
|
||||
this.verifyTokenHandler = () => {
|
||||
if (Array.isArray(window.cart) && window.cart.length > 0) {
|
||||
this.processStripeOrderWithCart([...window.cart]); // Copy the cart
|
||||
|
||||
// Clear window.cart after copying
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
} else {
|
||||
this.setState({
|
||||
completionError: "Cart is empty. Please add items to your cart before placing an order."
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up listener
|
||||
if (this.verifyTokenHandler) {
|
||||
window.removeEventListener('cart', this.verifyTokenHandler);
|
||||
this.verifyTokenHandler = null;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('cart', this.verifyTokenHandler);
|
||||
|
||||
// Set up a timeout as fallback (in case verifyToken fails)
|
||||
this.verifyTokenTimeout = setTimeout(() => {
|
||||
if (Array.isArray(window.cart) && window.cart.length > 0) {
|
||||
this.processStripeOrderWithCart([...window.cart]);
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
} else {
|
||||
this.setState({
|
||||
completionError: "Unable to load cart data. Please refresh the page and try again."
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (this.verifyTokenHandler) {
|
||||
window.removeEventListener('cart', this.verifyTokenHandler);
|
||||
this.verifyTokenHandler = null;
|
||||
}
|
||||
}, 5000); // 5 second timeout
|
||||
}
|
||||
|
||||
processStripeOrderWithCart(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.processStripeOrder();
|
||||
});
|
||||
}
|
||||
|
||||
processStripeOrder() {
|
||||
// If no original cart items, don't process
|
||||
if (!this.getState().originalCartItems || this.getState().originalCartItems.length === 0) {
|
||||
this.setState({ completionError: "Cart is empty. Please add items to your cart before placing an order." });
|
||||
return;
|
||||
}
|
||||
|
||||
// If socket is ready, process immediately
|
||||
const context = this.getContext();
|
||||
if (context && context.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.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);
|
||||
this.socketHandler = null;
|
||||
}
|
||||
};
|
||||
window.addEventListener('cart', this.socketHandler);
|
||||
}
|
||||
|
||||
sendStripeOrder() {
|
||||
const state = this.getState();
|
||||
|
||||
// Don't process if already processing or completed
|
||||
if (state.isCompletingOrder || state.orderCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isCompletingOrder: true, completionError: null });
|
||||
|
||||
const {
|
||||
deliveryMethod,
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
originalCartItems,
|
||||
note,
|
||||
saveAddressForFuture,
|
||||
} = state;
|
||||
|
||||
const deliveryCost = this.getDeliveryCost();
|
||||
|
||||
const orderData = {
|
||||
items: originalCartItems,
|
||||
invoiceAddress,
|
||||
deliveryAddress: useSameAddress ? invoiceAddress : deliveryAddress,
|
||||
deliveryMethod,
|
||||
paymentMethod: "stripe",
|
||||
deliveryCost,
|
||||
note,
|
||||
domain: window.location.origin,
|
||||
stripeData: this.paymentCompletionData ? {
|
||||
paymentIntent: this.paymentCompletionData.paymentIntent,
|
||||
paymentIntentClientSecret: this.paymentCompletionData.paymentIntentClientSecret,
|
||||
redirectStatus: this.paymentCompletionData.redirectStatus,
|
||||
} : null,
|
||||
saveAddressForFuture,
|
||||
};
|
||||
|
||||
// Emit stripe order to backend via socket.io
|
||||
const context = this.getContext();
|
||||
context.emit("issueStripeOrder", orderData, (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
orderCompleted: true,
|
||||
completionError: null,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: response.error || "Failed to complete order. Please try again.",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process regular (non-Stripe) orders
|
||||
processRegularOrder(orderData) {
|
||||
const context = this.getContext();
|
||||
if (context) {
|
||||
context.emit("issueOrder", orderData, (response) => {
|
||||
if (response.success) {
|
||||
// Clear the cart
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
|
||||
// Reset state and navigate to orders tab
|
||||
this.setState({
|
||||
isCheckingOut: false,
|
||||
cartItems: [],
|
||||
isCompletingOrder: false,
|
||||
completionError: null,
|
||||
});
|
||||
|
||||
// Call success callback if provided
|
||||
if (this.onOrderSuccess) {
|
||||
this.onOrderSuccess();
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: response.error || "Failed to complete order. Please try again.",
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error("Socket context not available");
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: "Cannot connect to server. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create Stripe payment intent
|
||||
createStripeIntent(totalAmount, loadStripeComponent) {
|
||||
const context = this.getContext();
|
||||
if (context) {
|
||||
context.emit(
|
||||
"createStripeIntent",
|
||||
{ amount: totalAmount },
|
||||
(response) => {
|
||||
if (response.success) {
|
||||
loadStripeComponent(response.client_secret);
|
||||
} else {
|
||||
console.error("Error:", response.error);
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: response.error || "Failed to create Stripe payment intent. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.error("Socket context not available");
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: "Cannot connect to server. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate delivery cost
|
||||
getDeliveryCost() {
|
||||
const { deliveryMethod, paymentMethod } = this.getState();
|
||||
let cost = 0;
|
||||
|
||||
switch (deliveryMethod) {
|
||||
case "DHL":
|
||||
cost = 6.99;
|
||||
break;
|
||||
case "DPD":
|
||||
cost = 4.9;
|
||||
break;
|
||||
case "Sperrgut":
|
||||
cost = 28.99;
|
||||
break;
|
||||
case "Abholung":
|
||||
cost = 0;
|
||||
break;
|
||||
default:
|
||||
cost = 6.99;
|
||||
}
|
||||
|
||||
// Add onDelivery surcharge if selected
|
||||
if (paymentMethod === "onDelivery") {
|
||||
cost += 8.99;
|
||||
}
|
||||
|
||||
return cost;
|
||||
}
|
||||
|
||||
// Helper method to get current state (to be overridden by component)
|
||||
getState() {
|
||||
throw new Error("getState method must be implemented by the component");
|
||||
}
|
||||
|
||||
// Set callback for order success
|
||||
setOrderSuccessCallback(callback) {
|
||||
this.onOrderSuccess = callback;
|
||||
}
|
||||
}
|
||||
|
||||
export default OrderProcessingService;
|
||||
106
src/components/profile/OrderSummary.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
|
||||
const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
||||
const currencyFormatter = new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
});
|
||||
|
||||
// Calculate VAT breakdown for cart items (similar to CartDropdown)
|
||||
const cartVatCalculations = cartItems.reduce((acc, item) => {
|
||||
const totalItemPrice = item.price * item.quantity;
|
||||
const netPrice = totalItemPrice / (1 + item.vat / 100);
|
||||
const vatAmount = totalItemPrice - netPrice;
|
||||
|
||||
acc.totalGross += totalItemPrice;
|
||||
acc.totalNet += netPrice;
|
||||
|
||||
if (item.vat === 7) {
|
||||
acc.vat7 += vatAmount;
|
||||
} else if (item.vat === 19) {
|
||||
acc.vat19 += vatAmount;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
|
||||
|
||||
// Calculate shipping VAT (19% VAT for shipping costs)
|
||||
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
|
||||
const shippingVat = deliveryCost - shippingNetPrice;
|
||||
|
||||
// Combine totals - add shipping VAT to the 19% VAT total
|
||||
const totalVat7 = cartVatCalculations.vat7;
|
||||
const totalVat19 = cartVatCalculations.vat19 + shippingVat;
|
||||
const totalGross = cartVatCalculations.totalGross + deliveryCost;
|
||||
|
||||
return (
|
||||
<Box sx={{ my: 3, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Bestellübersicht
|
||||
</Typography>
|
||||
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Waren (netto):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(cartVatCalculations.totalNet)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>Versandkosten (netto):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(shippingNetPrice)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{totalVat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat7)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{totalVat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat19)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{currencyFormatter.format(cartVatCalculations.totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>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 }}>Gesamtsumme:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
||||
{currencyFormatter.format(totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderSummary;
|
||||
246
src/components/profile/OrdersTab.js
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Alert,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
import OrderDetailsDialog from "./OrderDetailsDialog.js";
|
||||
|
||||
// Constants
|
||||
const statusTranslations = {
|
||||
new: "in Bearbeitung",
|
||||
pending: "Neu",
|
||||
processing: "in Bearbeitung",
|
||||
cancelled: "Storniert",
|
||||
shipped: "Verschickt",
|
||||
delivered: "Geliefert",
|
||||
};
|
||||
|
||||
const statusEmojis = {
|
||||
"in Bearbeitung": "⚙️",
|
||||
pending: "⏳",
|
||||
processing: "🔄",
|
||||
cancelled: "❌",
|
||||
Verschickt: "🚚",
|
||||
Geliefert: "✅",
|
||||
Storniert: "❌",
|
||||
Retoure: "↩️",
|
||||
"Teil Retoure": "↪️",
|
||||
"Teil geliefert": "⚡",
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
"in Bearbeitung": "#ed6c02", // orange
|
||||
pending: "#ff9800", // orange for pending
|
||||
processing: "#2196f3", // blue for processing
|
||||
cancelled: "#d32f2f", // red for cancelled
|
||||
Verschickt: "#2e7d32", // green
|
||||
Geliefert: "#2e7d32", // green
|
||||
Storniert: "#d32f2f", // red
|
||||
Retoure: "#9c27b0", // purple
|
||||
"Teil Retoure": "#9c27b0", // purple
|
||||
"Teil geliefert": "#009688", // teal
|
||||
};
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
});
|
||||
|
||||
// Orders Tab Content Component
|
||||
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 socket = useContext(SocketContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleViewDetails = useCallback(
|
||||
(orderId) => {
|
||||
const orderToView = orders.find((order) => order.orderId === orderId);
|
||||
if (orderToView) {
|
||||
setSelectedOrder(orderToView);
|
||||
setIsDetailsDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[orders]
|
||||
);
|
||||
|
||||
const fetchOrders = useCallback(() => {
|
||||
if (socket && socket.connected) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
socket.emit("getOrders", (response) => {
|
||||
if (response.success) {
|
||||
setOrders(response.orders);
|
||||
} else {
|
||||
setError(response.error || "Failed to fetch orders.");
|
||||
}
|
||||
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) => {
|
||||
return statusTranslations[status] || status;
|
||||
};
|
||||
|
||||
const getStatusEmoji = (status) => {
|
||||
return statusEmojis[status] || "❓";
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
return statusColors[status] || "#757575"; // default gray
|
||||
};
|
||||
|
||||
const handleCloseDetailsDialog = () => {
|
||||
setIsDetailsDialogOpen(false);
|
||||
setSelectedOrder(null);
|
||||
navigate("/profile", { replace: true });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ p: 3, display: "flex", justifyContent: "center" }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{orders.length > 0 ? (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bestellnummer</TableCell>
|
||||
<TableCell>Datum</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Artikel</TableCell>
|
||||
<TableCell align="right">Summe</TableCell>
|
||||
<TableCell align="center">Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{orders.map((order) => {
|
||||
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 (
|
||||
<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" }}
|
||||
>
|
||||
{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)}
|
||||
>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
Sie haben noch keine Bestellungen aufgegeben.
|
||||
</Alert>
|
||||
)}
|
||||
<OrderDetailsDialog
|
||||
open={isDetailsDialogOpen}
|
||||
onClose={handleCloseDetailsDialog}
|
||||
order={selectedOrder}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrdersTab;
|
||||
97
src/components/profile/PaymentConfirmationDialog.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Typography, Button } from "@mui/material";
|
||||
|
||||
class PaymentConfirmationDialog extends Component {
|
||||
render() {
|
||||
const {
|
||||
paymentCompletionData,
|
||||
isCompletingOrder,
|
||||
completionError,
|
||||
orderCompleted,
|
||||
onContinueShopping,
|
||||
onViewOrders,
|
||||
} = this.props;
|
||||
|
||||
if (!paymentCompletionData) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
mb: 3,
|
||||
p: 3,
|
||||
border: '2px solid',
|
||||
borderColor: paymentCompletionData.isSuccessful ? '#2e7d32' : '#d32f2f',
|
||||
borderRadius: 2,
|
||||
bgcolor: paymentCompletionData.isSuccessful ? '#e8f5e8' : '#ffebee'
|
||||
}}>
|
||||
<Typography variant="h5" sx={{
|
||||
mb: 2,
|
||||
color: paymentCompletionData.isSuccessful ? '#2e7d32' : '#d32f2f',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{paymentCompletionData.isSuccessful ? 'Zahlung erfolgreich!' : 'Zahlung fehlgeschlagen'}
|
||||
</Typography>
|
||||
|
||||
{paymentCompletionData.isSuccessful ? (
|
||||
<>
|
||||
{orderCompleted ? (
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
|
||||
🎉 Ihre Bestellung wurde erfolgreich abgeschlossen! Sie können jetzt Ihre Bestellungen einsehen.
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
|
||||
Ihre Zahlung wurde erfolgreich verarbeitet. Die Bestellung wird automatisch abgeschlossen.
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#d32f2f' }}>
|
||||
Ihre Zahlung konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut oder wählen Sie eine andere Zahlungsmethode.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{isCompletingOrder && (
|
||||
<Typography variant="body2" sx={{ mt: 2, color: '#2e7d32', p: 2, bgcolor: '#e8f5e8', borderRadius: 1 }}>
|
||||
Bestellung wird abgeschlossen...
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{completionError && (
|
||||
<Typography variant="body2" sx={{ mt: 2, color: '#d32f2f', p: 2, bgcolor: '#ffcdd2', borderRadius: 1 }}>
|
||||
{completionError}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{orderCompleted && (
|
||||
<Box sx={{ mt: 3, display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={onContinueShopping}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
borderColor: '#2e7d32',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(46, 125, 50, 0.04)',
|
||||
borderColor: '#1b5e20'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Weiter einkaufen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onViewOrders}
|
||||
variant="contained"
|
||||
sx={{
|
||||
bgcolor: '#2e7d32',
|
||||
'&:hover': { bgcolor: '#1b5e20' }
|
||||
}}
|
||||
>
|
||||
Zu meinen Bestellungen
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PaymentConfirmationDialog;
|
||||
178
src/components/profile/PaymentMethodSelector.js
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useCallback } from "react";
|
||||
import { Box, Typography, Radio } from "@mui/material";
|
||||
|
||||
const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeliveryMethodChange, cartItems = [], deliveryCost = 0 }) => {
|
||||
|
||||
// Calculate total amount
|
||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||
const totalAmount = subtotal + deliveryCost;
|
||||
|
||||
// Handle payment method changes with automatic delivery method adjustment
|
||||
const handlePaymentMethodChange = useCallback((event) => {
|
||||
const selectedPaymentMethod = event.target.value;
|
||||
|
||||
// If "Zahlung in der Filiale" is selected, force delivery method to "Abholung"
|
||||
if (selectedPaymentMethod === "cash" && deliveryMethod !== "Abholung") {
|
||||
if (onDeliveryMethodChange) {
|
||||
onDeliveryMethodChange({ target: { value: "Abholung" } });
|
||||
}
|
||||
}
|
||||
|
||||
onChange(event);
|
||||
}, [deliveryMethod, onDeliveryMethodChange, onChange]);
|
||||
|
||||
// Handle delivery method changes - auto-switch to stripe when DHL/DPD is selected
|
||||
useEffect(() => {
|
||||
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
|
||||
handlePaymentMethodChange({ target: { value: "stripe" } });
|
||||
}
|
||||
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
|
||||
|
||||
// Auto-switch to cash when total amount is 0
|
||||
useEffect(() => {
|
||||
if (totalAmount === 0 && paymentMethod !== "cash") {
|
||||
handlePaymentMethodChange({ target: { value: "cash" } });
|
||||
}
|
||||
}, [totalAmount, paymentMethod, handlePaymentMethodChange]);
|
||||
|
||||
const paymentOptions = [
|
||||
{
|
||||
id: "wire",
|
||||
name: "Überweisung",
|
||||
description: "Bezahlen Sie per Banküberweisung",
|
||||
disabled: totalAmount === 0,
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
name: "Karte oder Sofortüberweisung",
|
||||
description: totalAmount < 0.50 && totalAmount > 0
|
||||
? "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)"
|
||||
: "Bezahlen Sie per Karte oder Sofortüberweisung",
|
||||
disabled: totalAmount < 0.50 || (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung"),
|
||||
icons: [
|
||||
"/assets/images/giropay.png",
|
||||
"/assets/images/maestro.png",
|
||||
"/assets/images/mastercard.png",
|
||||
"/assets/images/visa_electron.png",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "onDelivery",
|
||||
name: "Nachnahme",
|
||||
description: "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
|
||||
disabled: totalAmount === 0 || deliveryMethod !== "DHL",
|
||||
icons: ["/assets/images/cash.png"],
|
||||
},
|
||||
{
|
||||
id: "cash",
|
||||
name: "Zahlung in der Filiale",
|
||||
description: "Bei Abholung bezahlen",
|
||||
disabled: false, // Always enabled
|
||||
icons: ["/assets/images/cash.png"],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Zahlungsart wählen
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{paymentOptions.map((option, index) => (
|
||||
<Box
|
||||
key={option.id}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
mb: index < paymentOptions.length - 1 ? 1 : 0,
|
||||
p: 1,
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: 1,
|
||||
cursor: option.disabled ? "not-allowed" : "pointer",
|
||||
opacity: option.disabled ? 0.6 : 1,
|
||||
transition: "all 0.2s ease-in-out",
|
||||
"&:hover": !option.disabled
|
||||
? {
|
||||
backgroundColor: "#f5f5f5",
|
||||
borderColor: "#2e7d32",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
}
|
||||
: {},
|
||||
...(paymentMethod === option.id &&
|
||||
!option.disabled && {
|
||||
backgroundColor: "#e8f5e8",
|
||||
borderColor: "#2e7d32",
|
||||
}),
|
||||
}}
|
||||
onClick={
|
||||
!option.disabled
|
||||
? () => handlePaymentMethodChange({ target: { value: option.id } })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Radio
|
||||
id={option.id}
|
||||
name="paymentMethod"
|
||||
value={option.id}
|
||||
checked={paymentMethod === option.id}
|
||||
onChange={handlePaymentMethodChange}
|
||||
disabled={option.disabled}
|
||||
sx={{ cursor: option.disabled ? "not-allowed" : "pointer" }}
|
||||
/>
|
||||
<Box sx={{ ml: 2, flex: 1 }}>
|
||||
<label
|
||||
htmlFor={option.id}
|
||||
style={{
|
||||
cursor: option.disabled ? "not-allowed" : "pointer",
|
||||
color: option.disabled ? "#999" : "inherit",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: option.disabled ? "#999" : "inherit" }}
|
||||
>
|
||||
{option.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ color: option.disabled ? "#ccc" : "text.secondary" }}
|
||||
>
|
||||
{option.description}
|
||||
</Typography>
|
||||
</label>
|
||||
</Box>
|
||||
{option.icons && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
ml: 2
|
||||
}}
|
||||
>
|
||||
{option.icons.map((iconPath, iconIndex) => (
|
||||
<img
|
||||
key={iconIndex}
|
||||
src={iconPath}
|
||||
alt={`Payment method ${iconIndex + 1}`}
|
||||
style={{
|
||||
height: "24px",
|
||||
width: "auto",
|
||||
opacity: option.disabled ? 0.5 : 1,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethodSelector;
|
||||
426
src/components/profile/SettingsTab.js
Normal file
@@ -0,0 +1,426 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
IconButton,
|
||||
Snackbar
|
||||
} from '@mui/material';
|
||||
import { ContentCopy } from '@mui/icons-material';
|
||||
|
||||
class SettingsTab extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
password: '',
|
||||
newEmail: '',
|
||||
passwordError: '',
|
||||
passwordSuccess: '',
|
||||
emailError: '',
|
||||
emailSuccess: '',
|
||||
loading: false,
|
||||
// API Key management state
|
||||
hasApiKey: false,
|
||||
apiKey: '',
|
||||
apiKeyDisplay: '',
|
||||
apiKeyError: '',
|
||||
apiKeySuccess: '',
|
||||
loadingApiKey: false,
|
||||
copySnackbarOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Load user data
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
try {
|
||||
const user = JSON.parse(storedUser);
|
||||
this.setState({ newEmail: user.email || '' });
|
||||
|
||||
// Check if user has an API key
|
||||
this.props.socket.emit('isApiKey', (response) => {
|
||||
if (response.success && response.hasApiKey) {
|
||||
this.setState({
|
||||
hasApiKey: true,
|
||||
apiKeyDisplay: '************'
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading user data:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdatePassword = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Reset states
|
||||
this.setState({
|
||||
passwordError: '',
|
||||
passwordSuccess: ''
|
||||
});
|
||||
|
||||
// Validation
|
||||
if (!this.state.currentPassword || !this.state.newPassword || !this.state.confirmPassword) {
|
||||
this.setState({ passwordError: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.newPassword !== this.state.confirmPassword) {
|
||||
this.setState({ passwordError: 'Die neuen Passwörter stimmen nicht überein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.newPassword.length < 8) {
|
||||
this.setState({ passwordError: 'Das neue Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
// Call socket.io endpoint to update password
|
||||
this.props.socket.emit('updatePassword',
|
||||
{ oldPassword: this.state.currentPassword, newPassword: this.state.newPassword },
|
||||
(response) => {
|
||||
this.setState({ loading: false });
|
||||
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
passwordSuccess: 'Passwort erfolgreich aktualisiert',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
passwordError: response.message || 'Fehler beim Aktualisieren des Passworts'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleUpdateEmail = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Reset states
|
||||
this.setState({
|
||||
emailError: '',
|
||||
emailSuccess: ''
|
||||
});
|
||||
|
||||
// Validation
|
||||
if (!this.state.password || !this.state.newEmail) {
|
||||
this.setState({ emailError: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.state.newEmail)) {
|
||||
this.setState({ emailError: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
// Call socket.io endpoint to update email
|
||||
this.props.socket.emit('updateEmail',
|
||||
{ password: this.state.password, email: this.state.newEmail },
|
||||
(response) => {
|
||||
this.setState({ loading: false });
|
||||
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
emailSuccess: 'E-Mail-Adresse erfolgreich aktualisiert',
|
||||
password: ''
|
||||
});
|
||||
|
||||
// Update user in sessionStorage
|
||||
try {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
const user = JSON.parse(storedUser);
|
||||
user.email = this.state.newEmail;
|
||||
sessionStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user in sessionStorage:', error);
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
emailError: response.message || 'Fehler beim Aktualisieren der E-Mail-Adresse'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleGenerateApiKey = () => {
|
||||
this.setState({
|
||||
apiKeyError: '',
|
||||
apiKeySuccess: '',
|
||||
loadingApiKey: true
|
||||
});
|
||||
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (!storedUser) {
|
||||
this.setState({
|
||||
apiKeyError: 'Benutzer nicht gefunden',
|
||||
loadingApiKey: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = JSON.parse(storedUser);
|
||||
|
||||
this.props.socket.emit('createApiKey', user.id, (response) => {
|
||||
this.setState({ loadingApiKey: false });
|
||||
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
hasApiKey: true,
|
||||
apiKey: response.apiKey,
|
||||
apiKeyDisplay: response.apiKey,
|
||||
apiKeySuccess: response.message || 'API-Schlüssel erfolgreich generiert'
|
||||
});
|
||||
|
||||
// After 10 seconds, hide the actual key and show asterisks
|
||||
setTimeout(() => {
|
||||
this.setState({ apiKeyDisplay: '************' });
|
||||
}, 10000);
|
||||
} else {
|
||||
this.setState({
|
||||
apiKeyError: response.message || 'Fehler beim Generieren des API-Schlüssels'
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating API key:', error);
|
||||
this.setState({
|
||||
apiKeyError: 'Fehler beim Generieren des API-Schlüssels',
|
||||
loadingApiKey: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleCopyToClipboard = () => {
|
||||
navigator.clipboard.writeText(this.state.apiKey).then(() => {
|
||||
this.setState({ copySnackbarOpen: true });
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = this.state.apiKey;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
this.setState({ copySnackbarOpen: true });
|
||||
});
|
||||
};
|
||||
|
||||
handleCloseSnackbar = () => {
|
||||
this.setState({ copySnackbarOpen: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Paper sx={{ p: 3}}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
Passwort ändern
|
||||
</Typography>
|
||||
|
||||
{this.state.passwordError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.passwordError}</Alert>}
|
||||
{this.state.passwordSuccess && <Alert severity="success" sx={{ mb: 2 }}>{this.state.passwordSuccess}</Alert>}
|
||||
|
||||
<Box component="form" onSubmit={this.handleUpdatePassword}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Aktuelles Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.currentPassword}
|
||||
onChange={(e) => this.setState({ currentPassword: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Neues Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.newPassword}
|
||||
onChange={(e) => this.setState({ newPassword: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Neues Passwort bestätigen"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.confirmPassword}
|
||||
onChange={(e) => this.setState({ confirmPassword: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||
disabled={this.state.loading}
|
||||
>
|
||||
{this.state.loading ? <CircularProgress size={24} /> : 'Passwort aktualisieren'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
E-Mail-Adresse ändern
|
||||
</Typography>
|
||||
|
||||
{this.state.emailError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.emailError}</Alert>}
|
||||
{this.state.emailSuccess && <Alert severity="success" sx={{ mb: 2 }}>{this.state.emailSuccess}</Alert>}
|
||||
|
||||
<Box component="form" onSubmit={this.handleUpdateEmail}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.password}
|
||||
onChange={(e) => this.setState({ password: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Neue E-Mail-Adresse"
|
||||
type="email"
|
||||
fullWidth
|
||||
value={this.state.newEmail}
|
||||
onChange={(e) => this.setState({ newEmail: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||
disabled={this.state.loading}
|
||||
>
|
||||
{this.state.loading ? <CircularProgress size={24} /> : 'E-Mail aktualisieren'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
API-Schlüssel
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Verwenden Sie Ihren API-Schlüssel für die Integration mit externen Anwendungen.
|
||||
</Typography>
|
||||
|
||||
{this.state.apiKeyError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.apiKeyError}</Alert>}
|
||||
{this.state.apiKeySuccess && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{this.state.apiKeySuccess}
|
||||
{this.state.apiKey && this.state.apiKeyDisplay !== '************' && (
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
Speichern Sie diesen Schlüssel sicher. Er wird aus Sicherheitsgründen in 10 Sekunden ausgeblendet.
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
API-Dokumentation: {' '}
|
||||
<a
|
||||
href={`${window.location.protocol}//${window.location.host}/api/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#2e7d32' }}
|
||||
>
|
||||
{`${window.location.protocol}//${window.location.host}/api/`}
|
||||
</a>
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 2 }}>
|
||||
<TextField
|
||||
label="API-Schlüssel"
|
||||
value={this.state.apiKeyDisplay}
|
||||
disabled
|
||||
fullWidth
|
||||
sx={{
|
||||
'& .MuiInputBase-input.Mui-disabled': {
|
||||
WebkitTextFillColor: this.state.apiKeyDisplay === '************' ? '#666' : '#000',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{this.state.apiKeyDisplay !== '************' && this.state.apiKey && (
|
||||
<IconButton
|
||||
onClick={this.handleCopyToClipboard}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
'&:hover': { bgcolor: 'rgba(46, 125, 50, 0.1)' }
|
||||
}}
|
||||
title="In Zwischenablage kopieren"
|
||||
>
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={this.handleGenerateApiKey}
|
||||
disabled={this.state.loadingApiKey}
|
||||
sx={{
|
||||
minWidth: 120,
|
||||
bgcolor: '#2e7d32',
|
||||
'&:hover': { bgcolor: '#1b5e20' }
|
||||
}}
|
||||
>
|
||||
{this.state.loadingApiKey ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
this.state.hasApiKey ? 'Regenerieren' : 'Generieren'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Snackbar
|
||||
open={this.state.copySnackbarOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={this.handleCloseSnackbar}
|
||||
message="API-Schlüssel in Zwischenablage kopiert"
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SettingsTab;
|
||||
20
src/components/withRouter.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useNavigate, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
export function withRouter(Component) {
|
||||
function ComponentWithRouterProp(props) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
navigate={navigate}
|
||||
location={location}
|
||||
params={params}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return ComponentWithRouterProp;
|
||||
}
|
||||
38
src/config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const config = {
|
||||
baseUrl: "https://seedheads.de",
|
||||
apiBaseUrl: "",
|
||||
googleClientId: "928121624463-jbgfdlgem22scs1k9c87ucg4ffvaik6o.apps.googleusercontent.com",
|
||||
stripePublishableKey: "pk_test_51R7lltRtpe3h1vwJzIrDb5bcEigTLBHrtqj9SiPX7FOEATSuD6oJmKc8xpNp49ShpGJZb2GShHIUqj4zlSIz4olj00ipOuOAnu",
|
||||
|
||||
// SEO and Business Information
|
||||
siteName: "SeedHeads.de",
|
||||
brandName: "SeedHeads",
|
||||
currency: "EUR",
|
||||
language: "de-DE",
|
||||
country: "DE",
|
||||
|
||||
// Shop Descriptions
|
||||
descriptions: {
|
||||
short: "SeedHeads - Online-Shop für Samen, Pflanzen und Gartenbedarf",
|
||||
long: "SeedHeads - Ihr Online-Shop für hochwertige Samen, Pflanzen und Gartenbedarf. Entdecken Sie unser großes Sortiment an Saatgut, Pflanzen und Gartenzubehör für Ihren grünen Daumen."
|
||||
},
|
||||
|
||||
// Keywords
|
||||
keywords: "Samen, Pflanzen, Gartenbedarf, Saatgut, Online-Shop, SeedHeads, Garten, Pflanzen kaufen",
|
||||
|
||||
// Shipping
|
||||
shipping: {
|
||||
defaultCost: "4.99 EUR",
|
||||
defaultService: "Standard"
|
||||
},
|
||||
|
||||
// Images
|
||||
images: {
|
||||
logo: "/assets/images/sh.png",
|
||||
placeholder: "/assets/images/nopicture.jpg"
|
||||
},
|
||||
|
||||
// Add other configuration values here as needed
|
||||
};
|
||||
|
||||
export default config;
|
||||
6
src/contexts/GoogleAuthContext.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import React, { createContext } from 'react';
|
||||
|
||||
// Create a new context for Google Auth
|
||||
const GoogleAuthContext = createContext(null);
|
||||
|
||||
export default GoogleAuthContext;
|
||||
7
src/contexts/SocketContext.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
// Create a new context for Socket.IO
|
||||
const SocketContext = React.createContext(null);
|
||||
|
||||
export const SocketConsumer = SocketContext.Consumer;
|
||||
export default SocketContext;
|
||||
269
src/data/configuratorData.js
Normal file
@@ -0,0 +1,269 @@
|
||||
// @note Dummy data for grow tent configurator - no backend calls
|
||||
export const tentShapes = [
|
||||
{
|
||||
id: '60x60',
|
||||
name: '60x60cm',
|
||||
description: 'Kompakt - ideal für kleine Räume',
|
||||
footprint: '60x60',
|
||||
minPlants: 1,
|
||||
maxPlants: 2,
|
||||
visualWidth: 60,
|
||||
visualDepth: 60
|
||||
},
|
||||
{
|
||||
id: '80x80',
|
||||
name: '80x80cm',
|
||||
description: 'Mittel - perfekte Balance',
|
||||
footprint: '80x80',
|
||||
minPlants: 2,
|
||||
maxPlants: 4,
|
||||
visualWidth: 80,
|
||||
visualDepth: 80
|
||||
},
|
||||
{
|
||||
id: '100x100',
|
||||
name: '100x100cm',
|
||||
description: 'Groß - für erfahrene Grower',
|
||||
footprint: '100x100',
|
||||
minPlants: 4,
|
||||
maxPlants: 6,
|
||||
visualWidth: 100,
|
||||
visualDepth: 100
|
||||
},
|
||||
{
|
||||
id: '120x60',
|
||||
name: '120x60cm',
|
||||
description: 'Rechteckig - maximale Raumnutzung',
|
||||
footprint: '120x60',
|
||||
minPlants: 3,
|
||||
maxPlants: 6,
|
||||
visualWidth: 120,
|
||||
visualDepth: 60
|
||||
}
|
||||
];
|
||||
|
||||
export const tentSizes = [
|
||||
// 60x60 tents
|
||||
{
|
||||
id: 'tent_60x60x140',
|
||||
name: 'Basic 140cm',
|
||||
description: 'Einsteigermodell',
|
||||
price: 89.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '60x60x140cm',
|
||||
coverage: '1-2 Pflanzen',
|
||||
shapeId: '60x60',
|
||||
height: 140
|
||||
},
|
||||
{
|
||||
id: 'tent_60x60x160',
|
||||
name: 'Premium 160cm',
|
||||
description: 'Mehr Höhe für größere Pflanzen',
|
||||
price: 109.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '60x60x160cm',
|
||||
coverage: '1-2 Pflanzen',
|
||||
shapeId: '60x60',
|
||||
height: 160
|
||||
},
|
||||
// 80x80 tents
|
||||
{
|
||||
id: 'tent_80x80x160',
|
||||
name: 'Standard 160cm',
|
||||
description: 'Beliebtes Mittelklasse-Modell',
|
||||
price: 129.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '80x80x160cm',
|
||||
coverage: '2-4 Pflanzen',
|
||||
shapeId: '80x80',
|
||||
height: 160
|
||||
},
|
||||
{
|
||||
id: 'tent_80x80x180',
|
||||
name: 'Pro 180cm',
|
||||
description: 'Extra Höhe für optimales Wachstum',
|
||||
price: 149.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '80x80x180cm',
|
||||
coverage: '2-4 Pflanzen',
|
||||
shapeId: '80x80',
|
||||
height: 180
|
||||
},
|
||||
// 100x100 tents
|
||||
{
|
||||
id: 'tent_100x100x180',
|
||||
name: 'Professional 180cm',
|
||||
description: 'Für anspruchsvolle Projekte',
|
||||
price: 189.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '100x100x180cm',
|
||||
coverage: '4-6 Pflanzen',
|
||||
shapeId: '100x100',
|
||||
height: 180
|
||||
},
|
||||
{
|
||||
id: 'tent_100x100x200',
|
||||
name: 'Expert 200cm',
|
||||
description: 'Maximum an Wuchshöhe',
|
||||
price: 219.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '100x100x200cm',
|
||||
coverage: '4-6 Pflanzen',
|
||||
shapeId: '100x100',
|
||||
height: 200
|
||||
},
|
||||
// 120x60 tents
|
||||
{
|
||||
id: 'tent_120x60x160',
|
||||
name: 'Rectangular 160cm',
|
||||
description: 'Platzsparend und effizient',
|
||||
price: 139.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '120x60x160cm',
|
||||
coverage: '3-6 Pflanzen',
|
||||
shapeId: '120x60',
|
||||
height: 160
|
||||
},
|
||||
{
|
||||
id: 'tent_120x60x180',
|
||||
name: 'Rectangular Pro 180cm',
|
||||
description: 'Optimale Raumausnutzung',
|
||||
price: 169.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '120x60x180cm',
|
||||
coverage: '3-6 Pflanzen',
|
||||
shapeId: '120x60',
|
||||
height: 180
|
||||
}
|
||||
];
|
||||
|
||||
export const lightTypes = [
|
||||
{
|
||||
id: 'led_quantum_board',
|
||||
name: 'LED Quantum Board',
|
||||
description: 'Energieeffizient, geringe Wärmeentwicklung',
|
||||
price: 159.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
wattage: '240W',
|
||||
coverage: 'Bis 100x100cm',
|
||||
spectrum: 'Vollspektrum',
|
||||
efficiency: 'Sehr hoch'
|
||||
},
|
||||
{
|
||||
id: 'led_cob',
|
||||
name: 'LED COB',
|
||||
description: 'Hochintensive COB-LEDs',
|
||||
price: 199.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
wattage: '300W',
|
||||
coverage: 'Bis 120x120cm',
|
||||
spectrum: 'Vollspektrum',
|
||||
efficiency: 'Hoch'
|
||||
},
|
||||
{
|
||||
id: 'hps_400w',
|
||||
name: 'HPS 400W',
|
||||
description: 'Bewährte Natriumdampflampe',
|
||||
price: 89.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
wattage: '400W',
|
||||
coverage: 'Bis 80x80cm',
|
||||
spectrum: 'Blüte-optimiert',
|
||||
efficiency: 'Mittel'
|
||||
},
|
||||
{
|
||||
id: 'cmh_315w',
|
||||
name: 'CMH 315W',
|
||||
description: 'Keramik-Metallhalogenid',
|
||||
price: 129.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
wattage: '315W',
|
||||
coverage: 'Bis 90x90cm',
|
||||
spectrum: 'Natürlich',
|
||||
efficiency: 'Hoch'
|
||||
}
|
||||
];
|
||||
|
||||
export const ventilationTypes = [
|
||||
{
|
||||
id: 'basic_exhaust',
|
||||
name: 'Basic Abluft-Set',
|
||||
description: 'Lüfter + Aktivkohlefilter',
|
||||
price: 79.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
airflow: '187 m³/h',
|
||||
noiseLevel: '35 dB',
|
||||
includes: ['Rohrventilator', 'Aktivkohlefilter', 'Aluflexrohr']
|
||||
},
|
||||
{
|
||||
id: 'premium_ventilation',
|
||||
name: 'Premium Klima-Set',
|
||||
description: 'Komplette Klimakontrolle',
|
||||
price: 159.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
airflow: '280 m³/h',
|
||||
noiseLevel: '28 dB',
|
||||
includes: ['EC-Lüfter', 'Aktivkohlefilter', 'Thermostat', 'Feuchtigkeitsmesser']
|
||||
},
|
||||
{
|
||||
id: 'pro_climate',
|
||||
name: 'Profi Klima-System',
|
||||
description: 'Automatisierte Klimasteuerung',
|
||||
price: 299.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
airflow: '420 m³/h',
|
||||
noiseLevel: '25 dB',
|
||||
includes: ['Digitaler Controller', 'EC-Lüfter', 'Aktivkohlefilter', 'Zu-/Abluft']
|
||||
}
|
||||
];
|
||||
|
||||
export const extras = [
|
||||
{
|
||||
id: 'ph_tester',
|
||||
name: 'pH-Messgerät',
|
||||
description: 'Digitales pH-Meter',
|
||||
price: 29.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Messung'
|
||||
},
|
||||
{
|
||||
id: 'nutrients_starter',
|
||||
name: 'Dünger Starter-Set',
|
||||
description: 'Komplettes Nährstoff-Set',
|
||||
price: 39.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Nährstoffe'
|
||||
},
|
||||
{
|
||||
id: 'grow_pots',
|
||||
name: 'Grow-Töpfe Set (5x)',
|
||||
description: '5x Stofftöpfe 11L',
|
||||
price: 24.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Töpfe'
|
||||
},
|
||||
{
|
||||
id: 'timer_socket',
|
||||
name: 'Zeitschaltuhr',
|
||||
description: 'Digitale Zeitschaltuhr',
|
||||
price: 19.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Steuerung'
|
||||
},
|
||||
{
|
||||
id: 'thermometer',
|
||||
name: 'Thermo-Hygrometer',
|
||||
description: 'Min/Max Temperatur & Luftfeuchtigkeit',
|
||||
price: 14.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Messung'
|
||||
},
|
||||
{
|
||||
id: 'pruning_shears',
|
||||
name: 'Gartenschere',
|
||||
description: 'Präzisions-Gartenschere',
|
||||
price: 16.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Werkzeug'
|
||||
}
|
||||
];
|
||||
59
src/index.css
Normal file
@@ -0,0 +1,59 @@
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url('../public/assets/fonts/roboto/Roboto-Light.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('../public/assets/fonts/roboto/Roboto-Regular.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('../public/assets/fonts/roboto/Roboto-Medium.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('../public/assets/fonts/roboto/Roboto-Bold.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SwashingtonCP';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: url('../public/assets/fonts/SwashingtonCP.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-y: scroll; /* Always show vertical scrollbar */
|
||||
}
|
||||
|
||||
/* Prevent Material-UI from changing scrollbar when modals open */
|
||||
body.MuiModal-open {
|
||||
overflow-y: scroll !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
18
src/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.js";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
// Create a wrapper component with our class-based GoogleAuthProvider
|
||||
// This avoids the "Invalid hook call" error from GoogleOAuthProvider
|
||||
const AppWithProviders = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||
root.render(<AppWithProviders />);
|
||||
176
src/pages/AGB.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
import LegalPage from './LegalPage.js';
|
||||
|
||||
const AGB = () => {
|
||||
const content = (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Liefer- & Versandbedingungen
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
1. Der Versand dauert zwischen 1 und 7 Tagen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
2. Die Ware bleibt bis zur vollständigen Bezahlung Eigentum von Growheads.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
3. Bei der Vermutung, dass die Ware durch den Transport beschädigt wurde oder Ware fehlt, ist die Versandverpackung zur Ansicht durch einen Gutachter aufzubewahren. Eine Beschädigung der Verpackung ist durch den Transporteur nach Art und Umfang auf dem Lieferschein zu bestätigen. Versandschäden müssen sofort schriftlich per Fax, Email oder Post an Growheads gemeldet werden. Dafür müssen Fotos von der beschädigten Ware sowie von dem beschädigten Versandkarton samt Adressaufkleber erstellt werden. Der beschädigte Versandkarton ist auch aufzubewahren. Diese werden benötigt um den Schaden der Transportfirma in Rechnung zu stellen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
4. Bei der Rücksendung mangelhafter Ware hat der Kunde Sorge zu tragen, dass die Ware ordnungsgemäß verpackt wird.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
5. Alle Rücksendungen sind vorher bei Growheads anzumelden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
6. Für das Zusenden von Gegenständen an uns trägt der Kunde die Gefahr, soweit es sich dabei nicht um die Rücksendung mangelhafter Ware handelt.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
7. Growheads ist berechtigt, die Ware durch die Deutsche Post/GLS oder einen Spediteur seiner Wahl, abholen zu lassen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
8. Die Portokosten werden nach Gewicht berechnet. Eventuelle Preiserhöhungen der Transportunternehmen (Maut, Treibstoffzuschläge) behält sich Growheads vor.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
9. Unsere Pakete werden in der Regel versendet mit: GLS, DHL & der Deutschen Post AG.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
10. Bei besonders schweren oder sperrigen Artikeln behalten wir uns Zuschläge auf die Lieferkosten vor. In der Regel sind diese Zuschläge in der Preisliste aufgeführt.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
11. Es kann per Vorkasse an die angegebene Bankverbindung überwiesen werden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
12. Kommt es zu einer Lieferverzögerung, die von uns zu vertreten ist, so ist die Dauer der Nachfrist, die der Käufer zu setzen berechtigt ist, auf zwei Wochen festgelegt. Die Frist beginnt mit Eingang der Nachfristsetzung bei Growheads.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
13. Offensichtliche Mängel der Ware ist sofort nach Lieferung schriftlich anzuzeigen. Kommt der Kunde dieser Verpflichtung nicht nach, so sind Gewährleistungsansprüche wegen offensichtlicher Mängel ausgeschlossen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
14. Rügt der Kunde einen Mangel, so hat er die mangelhafte Ware mit einer möglichst genauen Fehlerbeschreibung an uns zurück zu senden. Der Sendung ist eine Kopie unserer Rechnung beizulegen. Die Ware ist in der Originalverpackung zurück zu senden oder aber in einer Verpackung, welche die Ware entsprechend der Originalverpackung schützt, so dass Schäden auf dem Rücktransport vermieden werden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Beratung und Haftung
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
1. Anwendungstechnische Beratung geben wir nach bestem Wissen aufgrund des Standes unserer Erfahrung und Kenntnisse.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
2. Für die Beachtung gesetzlicher Vorschriften bei Lagerung, Weitertransport und Verwendung unserer Waren ist der Käufer verantwortlich.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Zahlungsbedingungen
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
1. Die Ware bleibt bis zur vollständigen Bezahlung Eigentum von Growheads.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
2. Rechnungen werden per Vorkasse auf unsere Bankverbindung überwiesen. Wenn Sie Vorkasse bezahlen, wird die Ware versendet sobald der Betrag auf unserem Konto gutgeschrieben ist.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Eigentumsvorbehalt
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Die gelieferte Ware bleibt so lange Eigentum von Growheads, bis der Käufer alle gegen ihn bestehenden Forderungen beglichen hat. Veräußert der Verkäufer die Ware, so tritt er schon jetzt die ihm aus dem Verkauf zustehenden Forderungen an uns ab. Kommt der Käufer mit seinen Zahlungen in Verzug, so können wir jederzeit die Herausgabe der Ware verlangen, ohne vom Vertrag zurückzutreten.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Belehrung nach Fernabsatzgesetz
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Die nachfolgende Belehrung gilt nur für Verträge, die zwischen Growheads und Verbrauchern durch Katalogbestellung, Internetbestellung oder durch sonstige Fern-Kommunikationsmittel zustande gekommen sind. Sie ist auf Verbraucher innerhalb der EG beschränkt.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
1. Wesentliche Merkmale der Ware
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Die wesentlichen Merkmale der Ware entnehmen Sie bitte den Erläuterungen im Katalog oder unserer Web-Site. Die Angebote in unserem Katalog und auf unserer Web-Site sind freibleibend. Bestellungen an uns verstehen sich als bindende Angebote. Diese kann Growheads innerhalb einer Frist von 14 Tagen ab Zugang der Bestellung durch eine Auftragsbestätigung oder durch Zusendung der Ware annehmen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
2. Vorbehalt
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Sollten nicht alle bestellten Artikel lieferbar sein, behalten wir uns Teillieferungen vor, soweit diese dem Kunden zumutbar sind. Einzelne Artikel können von den Abbildungen und Beschreibungen im Katalog und auf der Webseite eventuell abweichen. Dies gilt natürlich im Besonderen für Waren, die in Handarbeit gefertigt werden. Wir behalten uns daher vor, unter Umständen in Qualität und Preis gleichwertige Waren zu liefern.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
3. Preise und Steuern
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Die Preise der einzelnen Artikel inklusive Mehrwertsteuer können Sie dem Katalog bzw. unserer Website entnehmen. Die Preise verlieren ihre Gültigkeit mit Erscheinen eines neuen Kataloges.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
4. Alle Preise sind unter dem Vorbehalt von Fehlern oder Preisschwankungen. Sollte es zu einer Preisänderung kommen, so kann der Käufer von seinem Rückgaberecht gebrauch machen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
5. Gewährleistungsfrist
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Es gilt die gesetzliche Gewährleistungsfrist von 24 (vierundzwanzig) Monaten. Im Einzelfall können längere Fristen gelten, wenn diese vom Hersteller gewährt werden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
6. Rückgaberecht / Widerrufsrecht
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Dem Kunden steht ein 14-tägiges Rückgaberecht zu.
|
||||
Die Frist hierzu beginnt mit dem Eingang der Ware beim Kunden und ist gewahrt durch die rechtzeitige Absendung des Widerrufs an Growheads. Ausgenommen davon sind Lebensmittel und andere verderbliche Waren, sowie Spezialanfertigungen, oder Waren, die extra auf Wunsch des Kunden bestellt wurden. Die Rückgabe hat durch Rücksendung der Ware innerhalb der Frist zu erfolgen. Kann die Ware nicht versandt werden, so ist innerhalb der Frist ein Rücknahmeverlangen durch Brief, Postkarte, Email oder einen anderen dauerhaften Datenträger an uns zu richten. Zur Fristwahrung genügt die rechtzeitige Absendung an die unter 7) genannte Unternehmensanschrift. Der Widerruf bedarf keiner Begründung. Der Kaufpreis sowie eventuelle Liefer- und Versandkosten werden nach Eingang der Ware bei uns zurückerstattet. Entscheidend ist der Wert der zurückgesandten Ware zum Zeitpunkt des Kaufs, nicht der Wert der gesamten Bestellung. Growheads kann in der Regel eine Abholung bei Ihnen veranlassen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
7. Name und Anschrift des Unternehmens, Beanstandungen, Ladungen
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Growheads<br />
|
||||
Trachenberger Straße 14<br />
|
||||
01129 Dresden
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
8. Erfüllungsort und Gerichtsstand
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Erfüllungsort und Gerichtsstand für alle Ansprüche ist Dresden, soweit nicht zwingende gesetzliche Vorschriften dem entgegenstehen.
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
|
||||
return <LegalPage title="Allgemeine Geschäftsbedingungen" content={content} />;
|
||||
};
|
||||
|
||||
export default AGB;
|
||||
560
src/pages/AdminPage.js
Normal file
@@ -0,0 +1,560 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Paper,
|
||||
Box,
|
||||
Divider,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Tabs,
|
||||
Tab
|
||||
} from '@mui/material';
|
||||
import { Navigate, Link } from 'react-router-dom';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ADMIN_COLORS, getAdminStyles } from '../theme/adminColors.js';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
class AdminPage extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
users: {},
|
||||
user: null,
|
||||
stats: null,
|
||||
loading: true,
|
||||
redirect: false
|
||||
};
|
||||
}
|
||||
|
||||
checkUserLoggedIn = () => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (!storedUser) {
|
||||
this.setState({ redirect: true, user: null });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userData = JSON.parse(storedUser);
|
||||
if (!userData) {
|
||||
this.setState({ redirect: true, user: null });
|
||||
} else if (!this.state.user) {
|
||||
// Only update user if it's not already set
|
||||
this.setState({ user: userData, loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing user from sessionStorage:', error);
|
||||
this.setState({ redirect: true, user: null });
|
||||
}
|
||||
|
||||
// Once loading is complete
|
||||
if (this.state.loading) {
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleStorageChange = (e) => {
|
||||
if (e.key === 'user' && !e.newValue) {
|
||||
// User was removed from sessionStorage in another tab
|
||||
this.setState({ redirect: true, user: null });
|
||||
}
|
||||
}
|
||||
handleCartUpdated = (id,user,cart,id2) => {
|
||||
const users = this.state.users;
|
||||
if(user && user.email) id = user.email;
|
||||
if(id2) id=id2;
|
||||
if(cart) users[id] = cart;
|
||||
if(!users[id]) delete users[id];
|
||||
console.log(users);
|
||||
this.setState({ users });
|
||||
}
|
||||
|
||||
|
||||
|
||||
componentDidMount() {
|
||||
this.loadInitialData();
|
||||
this.addSocketListeners();
|
||||
this.checkUserLoggedIn();
|
||||
// Set up interval to regularly check login status
|
||||
this.checkLoginInterval = setInterval(this.checkUserLoggedIn, 1000);
|
||||
// Add storage event listener to detect when user logs out in other tabs
|
||||
window.addEventListener('storage', this.handleStorageChange);
|
||||
}
|
||||
|
||||
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 and reload data
|
||||
this.addSocketListeners();
|
||||
this.loadInitialData();
|
||||
} else if (wasConnected && !isNowConnected) {
|
||||
// Socket just disconnected, remove listeners
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.removeSocketListeners();
|
||||
// Clear interval and remove event listeners
|
||||
if (this.checkLoginInterval) {
|
||||
clearInterval(this.checkLoginInterval);
|
||||
}
|
||||
window.removeEventListener('storage', this.handleStorageChange);
|
||||
}
|
||||
|
||||
loadInitialData = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('getStats', (stats) => {
|
||||
console.log('AdminPage: getStats', JSON.stringify(stats,null,2));
|
||||
this.setState({stats: stats});
|
||||
});
|
||||
this.props.socket.emit('initialCarts', (carts) => {
|
||||
console.log('AdminPage: initialCarts', carts);
|
||||
if(carts && carts.success == true)
|
||||
{
|
||||
const users = {};
|
||||
for(const item of carts.carts){
|
||||
const user = {email:item.email};
|
||||
let id = item.clientUrlId || item.socketId;
|
||||
const cart = item.cart;
|
||||
if(user && user.email) id = user.email;
|
||||
if(cart) users[id] = cart;
|
||||
}
|
||||
this.setState({ users: users });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
formatPrice = (price) => {
|
||||
return typeof price === 'number'
|
||||
? `€${price.toFixed(2)}`
|
||||
: price;
|
||||
}
|
||||
|
||||
prepareChartData = () => {
|
||||
if (!this.state.stats || !this.state.stats.data || !this.state.stats.data.last30Days) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dailyData = this.state.stats.data.last30Days.dailyData || [];
|
||||
|
||||
// Sort data by date to ensure proper chronological order
|
||||
const sortedData = [...dailyData].sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
|
||||
const labels = sortedData.map(item => {
|
||||
const date = new Date(item.date);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
});
|
||||
|
||||
const socketConnections = sortedData.map(item => item.socket_connections || 0);
|
||||
const productViewCalls = sortedData.map(item => item.get_product_view_calls || 0);
|
||||
|
||||
return {
|
||||
labels,
|
||||
socketConnections,
|
||||
productViewCalls
|
||||
};
|
||||
}
|
||||
|
||||
getSocketConnectionsChartData = () => {
|
||||
const data = this.prepareChartData();
|
||||
if (!data) return null;
|
||||
|
||||
return {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Site Visits',
|
||||
data: data.socketConnections,
|
||||
borderColor: '#8be9fd', // terminal.ansiCyan
|
||||
backgroundColor: 'rgba(139, 233, 253, 0.2)', // terminal.ansiCyan with transparency
|
||||
tension: 0.1,
|
||||
pointBackgroundColor: '#8be9fd',
|
||||
pointBorderColor: '#8be9fd',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
getProductViewCallsChartData = () => {
|
||||
const data = this.prepareChartData();
|
||||
if (!data) return null;
|
||||
|
||||
return {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Product Detail Page Visits',
|
||||
data: data.productViewCalls,
|
||||
backgroundColor: 'rgba(255, 121, 198, 0.2)', // terminal.ansiMagenta with transparency
|
||||
borderColor: '#ff79c6', // terminal.ansiMagenta
|
||||
borderWidth: 2,
|
||||
tension: 0.1,
|
||||
pointBackgroundColor: '#ff79c6',
|
||||
pointBorderColor: '#ff79c6',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
getChartOptions = (title) => ({
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
font: {
|
||||
family: ADMIN_COLORS.fontFamily
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: title,
|
||||
color: ADMIN_COLORS.primary,
|
||||
font: {
|
||||
family: ADMIN_COLORS.fontFamily,
|
||||
weight: 'bold'
|
||||
}
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
font: {
|
||||
family: ADMIN_COLORS.fontFamily
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: ADMIN_COLORS.border
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
font: {
|
||||
family: ADMIN_COLORS.fontFamily
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: ADMIN_COLORS.border
|
||||
}
|
||||
},
|
||||
},
|
||||
backgroundColor: ADMIN_COLORS.surfaceBackground,
|
||||
color: ADMIN_COLORS.primaryText
|
||||
})
|
||||
|
||||
render() {
|
||||
const { users } = this.state;
|
||||
|
||||
if (this.state.redirect || (!this.state.loading && !this.state.user)) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
// Check if current user is admin
|
||||
if (this.state.user && !this.state.user.admin) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
const hasUsers = Object.keys(users).length > 0;
|
||||
|
||||
const socketConnectionsData = this.getSocketConnectionsChartData();
|
||||
const productViewCallsData = this.getProductViewCallsChartData();
|
||||
|
||||
const styles = getAdminStyles();
|
||||
|
||||
return (
|
||||
<Box sx={styles.pageContainer}>
|
||||
<Container
|
||||
maxWidth="lg"
|
||||
sx={{
|
||||
py: 6
|
||||
}}
|
||||
>
|
||||
{/* Admin Navigation Tabs */}
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
mb: 3,
|
||||
...styles.tabBar
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={0}
|
||||
indicatorColor="primary"
|
||||
sx={{
|
||||
px: 2,
|
||||
'& .MuiTabs-indicator': {
|
||||
backgroundColor: ADMIN_COLORS.primary
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
label="Dashboard"
|
||||
component={Link}
|
||||
to="/admin"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: ADMIN_COLORS.primary,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Users"
|
||||
component={Link}
|
||||
to="/admin/users"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
'&:hover': {
|
||||
color: ADMIN_COLORS.primaryBright
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Server Logs"
|
||||
component={Link}
|
||||
to="/admin/logs"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
'&:hover': {
|
||||
color: ADMIN_COLORS.primaryBright
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
{/* Analytics Charts Section */}
|
||||
{(socketConnectionsData || productViewCallsData) && (
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
...styles.contentPaper
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
gutterBottom
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
...styles.primaryHeading
|
||||
}}
|
||||
>
|
||||
<BarChartIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
|
||||
30-Day Analytics
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3} sx={{ mt: 1 }}>
|
||||
{socketConnectionsData && (
|
||||
<Grid size={{ xs: 12, lg: 6 }}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={styles.card}
|
||||
>
|
||||
<CardContent sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<Line
|
||||
data={socketConnectionsData}
|
||||
options={this.getChartOptions('Site Visits')}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{productViewCallsData && (
|
||||
<Grid size={{ xs: 12, lg: 6 }}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={styles.card}
|
||||
>
|
||||
<CardContent sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<Line
|
||||
data={productViewCallsData}
|
||||
options={this.getChartOptions('Product Detail Page Visits')}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
...styles.contentPaper
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
gutterBottom
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
...styles.primaryHeading
|
||||
}}
|
||||
>
|
||||
<ShoppingCartIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
|
||||
Active User Carts
|
||||
</Typography>
|
||||
|
||||
{!hasUsers && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
mt: 2,
|
||||
...styles.secondaryText
|
||||
}}
|
||||
>
|
||||
No active user carts at the moment.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{hasUsers && (
|
||||
<Grid container spacing={3} sx={{ mt: 1 }}>
|
||||
{Object.keys(users).map((user, i) => {
|
||||
const cartItems = Object.keys(users[user]);
|
||||
const totalValue = cartItems.reduce((total, item) => {
|
||||
return total + (parseFloat(users[user][item].price) || 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Grid size={{ xs: 12, md: 6 }} key={i}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={styles.card}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
...styles.primaryHeading
|
||||
}}
|
||||
>
|
||||
<PersonIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
|
||||
{user}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ mb: 2, borderColor: ADMIN_COLORS.border }} />
|
||||
|
||||
<List disablePadding>
|
||||
{cartItems.map((item, j) => (
|
||||
<ListItem
|
||||
key={j}
|
||||
divider={j < cartItems.length - 1}
|
||||
sx={{
|
||||
py: 1,
|
||||
borderBottom: j < cartItems.length - 1 ? `1px solid ${ADMIN_COLORS.border}` : 'none'
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={users[user][item].name}
|
||||
secondary={users[user][item].quantity+' x '+this.formatPrice(users[user][item].price)}
|
||||
primaryTypographyProps={{
|
||||
fontWeight: 'medium',
|
||||
...styles.primaryText
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
color: ADMIN_COLORS.warning,
|
||||
fontWeight: 'bold',
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ mt: 2, textAlign: 'right' }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: ADMIN_COLORS.primaryBright,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
>
|
||||
Total: {this.formatPrice(totalValue)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AdminPage;
|
||||