This commit is contained in:
seb
2025-07-02 12:49:06 +02:00
commit edbd56f6a9
123 changed files with 32598 additions and 0 deletions

12
.browserslistrc Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

68
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
};
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
View 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
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/assets/images/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
public/assets/images/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
public/assets/images/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
public/assets/images/8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
public/assets/images/gg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
public/assets/images/sh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

39
public/index.html Normal file
View 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
View 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 };

View 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
View 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
View 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 };

View 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
View 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
View 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;

View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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;

View 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
View 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
View 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;

View 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
View 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;

View 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;

View 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;

View 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);

View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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;

View 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,
}}
>
&nbsp;
</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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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;

View File

@@ -0,0 +1,6 @@
import React, { createContext } from 'react';
// Create a new context for Google Auth
const GoogleAuthContext = createContext(null);
export default GoogleAuthContext;

View File

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

View File

@@ -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
View 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
View 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
View 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
View 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;

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