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

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;

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Typography } from '@mui/material';
import LegalPage from './LegalPage.js';
const Batteriegesetzhinweise = () => {
const content = (
<>
<Typography variant="body1" paragraph>
Im Zusammenhang mit dem Vertrieb von Batterien oder mit der Lieferung von Geräten, die Batterien enthalten, sind wir verpflichtet, Sie auf folgendes hinzuweisen:
</Typography>
<Typography variant="body1" paragraph>
Sie sind zur Rückgabe gebrauchter Batterien als Endnutzer gesetzlich verpflichtet. Sie können Altbatterien, die wir als Neubatterien im Sortiment führen oder geführt haben, unentgeltlich an unserem Versandlager (Versandadresse) zurückgeben.
</Typography>
<Typography variant="body1" paragraph>
Die auf den Batterien abgebildeten Symbole haben folgende Bedeutung:
</Typography>
<Typography variant="body1" paragraph>
Das Symbol der durchgekreuzten Mülltonne bedeutet, dass die Batterie nicht in den Hausmüll gegeben werden darf.
</Typography>
<Typography variant="body1" sx={{ ml: 2 }} paragraph>
Pb = Batterie enthält mehr als 0,004 Masseprozent Blei<br />
Cd = Batterie enthält mehr als 0,002 Masseprozent Cadmium<br />
Hg = Batterie enthält mehr als 0,0005 Masseprozent Quecksilber.
</Typography>
</>
);
return <LegalPage title="Batteriegesetzhinweise" content={content} />;
};
export default Batteriegesetzhinweise;

149
src/pages/Datenschutz.js Normal file
View File

@@ -0,0 +1,149 @@
import React from 'react';
import Typography from '@mui/material/Typography';
import LegalPage from './LegalPage.js';
const Datenschutz = () => {
const content = (
<>
<Typography variant="h6" gutterBottom>
Verantwortlich im Sinne der Datenschutzgesetzes:
</Typography>
<Typography variant="body1" paragraph>
Growheads<br />
Trachenberger Straße 14<br />
01129 Dresden
</Typography>
<Typography variant="body1" paragraph>
Soweit nachstehend keine anderen Angaben gemacht werden, ist die Bereitstellung Ihrer personenbezogenen Daten weder gesetzlich oder vertraglich vorgeschrieben, noch für einen Vertragsabschluss erforderlich. Sie sind zur Bereitstellung der Daten nicht verpflichtet. Eine Nichtbereitstellung hat keine Folgen. Dies gilt nur soweit bei den nachfolgenden Verarbeitungsvorgängen keine anderweitige Angabe gemacht wird. "Personenbezogene Daten" sind alle Informationen, die sich auf eine identifizierte oder identifizierbare natürliche Person beziehen.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Auskunft, Löschung, Sperrung
</Typography>
<Typography variant="body1" paragraph>
Zu jedem Zeitpunkt können Sie sich über die personenbezogenen Daten, deren Herkunft und Empfänger und den Nutzen der Datenverarbeitung informieren und unentgeltlich eine Korrektur, Sperrung oder Löschung dieser Daten verlangen. Bitte nutzen Sie dafür die im Footer der Seite oder im Impressum angegebenen Kontaktmöglichkeiten. Für weitere Fragen zum Thema stehen wir Ihnen ebenfalls jederzeit zur Verfügung. Bitte beachten Sie, das wir Rechnungsdaten, Bankdaten und Daten die zu einem Versanddienstleister gegangen sind nicht löschen dürfen und nicht werden. Daten die gelöscht werden können sind: Kundenkonto auf dem Webserver, so wie in der Warenwirtschaft und Emails, die nicht unmittelbar zu einer Bestellung gehören.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Server-Logfiles
</Typography>
<Typography variant="body1" paragraph>
Sie können unsere Webseiten besuchen, ohne Angaben zu Ihrer Person zu machen. Es werden bei jedem Zugriff auf unsere Website Nutzungsdaten durch Ihren Internetbrowser übermittelt und in Protokolldaten (Server-Logfiles) gespeichert. Zu diesen gespeicherten Daten gehören z.B. Name der aufgerufenen Seite, Datum und Uhrzeit des Abrufs, übertragene Datenmenge und der anfragende Provider. Diese Daten dienen ausschließlich der Gewährleistung eines störungsfreien Betriebs unserer Website und zur Verbesserung unseres Angebotes. Diese Daten sind nicht personenbezogen. Es erfolgt keine Zusammenführung dieser Daten mit anderen Datenquellen. Wenn uns konkrete Anhaltspunkte für eine rechtswidrige Nutzung bekannt werden behalten wir uns das Recht vor, diese Daten nachträglich zu überprüfen.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Kundenkonto
</Typography>
<Typography variant="body1" paragraph>
Bei der Eröffnung eines Kundenkontos erheben wir Ihre personenbezogenen Daten in dem dort angegeben Umfang. Die Datenverarbeitung dient dem Zweck, Ihr Einkaufserlebnis zu verbessern und die Bestellabwicklung zu vereinfachen. Die Verarbeitung erfolgt auf Grundlage des Art.&nbsp;6&nbsp;(1)&nbsp;lit.&nbsp;a&nbsp;DSGVO mit Ihrer Einwilligung. Sie können Ihre Einwilligung jederzeit durch Mitteilung an uns widerrufen, ohne dass die Rechtmäßigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten Verarbeitung berührt wird. Ihr Kundenkonto wird anschließend gelöscht.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Anmeldung mit Google (Google Single Sign-On)
</Typography>
<Typography variant="body1" paragraph>
Wir bieten Ihnen die Möglichkeit, sich für Ihr Kundenkonto mit Ihrem Google-Konto anzumelden. Wenn Sie die Funktion "Mit Google anmelden" nutzen, erfolgt die Authentifizierung über den Dienst Google Single Sign-On. Dabei können Cookies von Google auf Ihrem Endgerät gespeichert werden, die für den Anmeldeprozess und die Authentifizierung erforderlich sind. Im Rahmen der Google-Anmeldung erhalten wir von Google bestimmte personenbezogene Daten zur Verifizierung Ihrer Identität. Insbesondere übermittelt Google an uns Ihren Namen, Ihre E-Mail-Adresse sowie falls in Ihrem Google-Konto hinterlegt Ihr Profilbild. Diese Informationen werden von Google bereitgestellt, sobald Sie sich mit Ihrem Google-Konto bei unserem Online-Shop anmelden. Google kann als Drittanbieter auf diese Daten zugreifen und sie verarbeiten; hierbei kann es auch zu einer Datenübermittlung in die USA kommen. Wir haben mit Google Standarddatenschutzklauseln nach Art. 46 Abs. 2 lit. c DSGVO abgeschlossen, um ein angemessenes Datenschutzniveau bei der Übermittlung Ihrer Daten sicherzustellen. Weitere Details zur Datenverarbeitung durch Google finden Sie in der Google-Datenschutzerklärung (unter <a href="https://policies.google.com/privacy?hl=de" target="_blank" rel="noopener noreferrer">policies.google.com/privacy?hl=de</a>).
</Typography>
<Typography variant="body1" paragraph>
<strong>Rechtsgrundlagen:</strong> Die Verarbeitung der Daten im Zusammenhang mit der Google-Anmeldung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO (Durchführung vorvertraglicher Maßnahmen und Vertragserfüllung, z. B. zur Erstellung und Nutzung Ihres Kundenkontos) sowie Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse unsererseits, Ihnen eine schnelle und komfortable Anmeldemöglichkeit bereitzustellen).
</Typography>
<Typography variant="body1" paragraph>
<strong>Freiwillige Nutzung:</strong> Die Nutzung der "Mit Google anmelden"-Funktion ist freiwillig. Sie können unseren Online-Shop und Ihr Kundenkonto selbstverständlich auch ohne Google SSO nutzen, indem Sie sich regulär mit E-Mail-Adresse und Passwort registrieren bzw. anmelden. Wenn Sie sich für die Google-Anmeldung entscheiden, können Sie diese Verbindung jederzeit wieder aufheben, indem Sie die Verknüpfung in Ihren Google-Konto-Einstellungen trennen.
</Typography>
<Typography variant="body1" paragraph>
<strong>Ihre Rechte:</strong> Bezüglich der über Google SSO verarbeiteten personenbezogenen Daten stehen Ihnen die gesetzlichen Betroffenenrechte zu. Insbesondere haben Sie das Recht, Auskunft über die zu Ihrer Person gespeicherten Daten zu erhalten (Art. 15 DSGVO), Berichtigung unrichtiger Daten (Art. 16 DSGVO) oder Löschung Ihrer Daten zu verlangen (Art. 17 DSGVO). Ferner haben Sie das Recht auf Einschränkung der Verarbeitung Ihrer Daten (Art. 18 DSGVO) und ein Recht auf Datenübertragbarkeit (Art. 20 DSGVO). Soweit wir die Verarbeitung auf unser berechtigtes Interesse stützen, können Sie Widerspruch gegen die Verarbeitung einlegen (Art. 21 DSGVO). Zudem können Sie sich jederzeit mit einer Beschwerde an die zuständige Datenschutzaufsichtsbehörde wenden. Ihre bereits bestehenden Rechte und Wahlmöglichkeiten aus der übrigen Datenschutzerklärung gelten selbstverständlich auch für die Nutzung von Google Anmelden.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Erhebung, Verarbeitung und Nutzung personenbezogener Daten bei Bestellungen
</Typography>
<Typography variant="body1" paragraph>
Bei der Bestellung erheben und verwenden wir Ihre personenbezogenen Daten nur, soweit dies zur Erfüllung und Abwicklung Ihrer Bestellung sowie zur Bearbeitung Ihrer Anfragen erforderlich ist. Die Bereitstellung der Daten ist für den Vertragsschluss erforderlich. Eine Nichtbereitstellung hat zur Folge, dass kein Vertrag geschlossen werden kann. Die Verarbeitung erfolgt auf Grundlage des Art.&nbsp;6&nbsp;(1)&nbsp;lit.&nbsp;b&nbsp;DSGVO und ist für die Erfüllung eines Vertrags mit Ihnen erforderlich. Eine Weitergabe Ihrer Daten an Dritte ohne Ihre ausdrückliche Einwilligung erfolgt nicht. Ausgenommen hiervon sind lediglich unsere Dienstleistungspartner, die wir zur Abwicklung des Vertragsverhältnisses benötigen oder Dienstleister, derer wir uns im Rahmen einer Auftragsverarbeitung bedienen. Neben den in den jeweiligen Klauseln dieser Datenschutzerklärung benannten Empfängern sind dies beispielsweise Empfänger folgender Kategorien: Versanddienstleister, Zahlungsdienstleister, Warenwirtschaftsdienstleister, Diensteanbieter für die Bestellabwicklung, Webhoster, IT-Dienstleister und Dropshipping-Händler. In allen Fällen beachten wir strikt die gesetzlichen Vorgaben. Der Umfang der Datenübermittlung beschränkt sich auf ein Mindestmaß.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Verwendung der E-Mail-Adresse für die Zusendung von Newslettern
</Typography>
<Typography variant="body1" paragraph>
Wir nutzen Ihre E-Mail-Adresse unabhängig von der Vertragsabwicklung ausschließlich für eigene Werbezwecke zum Newsletterversand, sofern Sie dem ausdrücklich zugestimmt haben. Die Verarbeitung erfolgt auf Grundlage des Art.&nbsp;6&nbsp;(1)&nbsp;lit.&nbsp;a&nbsp;DSGVO mit Ihrer Einwilligung. Sie können die Einwilligung jederzeit widerrufen, ohne dass die Rechtmäßigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten Verarbeitung berührt wird. Sie können dazu den Newsletter jederzeit unter Nutzung des entsprechenden Links im Newsletter oder durch Mitteilung an uns abbestellen. Ihre E-Mail-Adresse wird danach aus dem Verteiler entfernt. Ihre Daten werden dabei an einen Dienstleister für E-Mail-Marketing im Rahmen einer Auftragsverarbeitung weitergegeben. Eine Weitergabe an sonstige Dritte erfolgt nicht. Ihre Daten werden an ein Drittland übermittelt, für welches ein Angemessenheitsbeschluss der Europäischen Kommission vorhanden ist.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Einsatz eines KI-Chatbots (OpenAI API)
</Typography>
<Typography variant="body1" paragraph>
Wir verwenden auf unserer Website einen KI-gestützten Chatbot, der über die Programmierschnittstelle (API) des Anbieters OpenAI betrieben wird. Der Chatbot dient dazu, Anfragen von Besuchern effizient und automatisiert zu beantworten und somit eine Support-Funktion bereitzustellen. Wenn Sie den Chatbot nutzen, werden Ihre Eingaben vom System verarbeitet, um passende Antworten zu generieren. Die Verarbeitung erfolgt anonymisiert es werden keine IP-Adressen oder sonstige personenbezogene Daten (wie Name oder Kontaktdaten) erfasst oder gespeichert.
</Typography>
<Typography variant="body1" paragraph>
Rechtsgrundlage für den Einsatz des Chatbots ist unser berechtigtes Interesse nach Art.&nbsp;6&nbsp;Abs.&nbsp;1&nbsp;lit.&nbsp;f&nbsp;DSGVO. Dieses Interesse liegt in der Bereitstellung eines effektiven Besucher-Supports sowie in der Verbesserung der Nutzererfahrung auf unserer Website.
</Typography>
<Typography variant="body1" paragraph>
Empfänger der Chat-Daten ist OpenAI (OpenAI OpCo, LLC) als technischer Dienstleister. OpenAI verarbeitet die übermittelten Chat-Inhalte auf seinen Servern ausschließlich zum Zweck der Antwortgenerierung. OpenAI handelt hierbei als Auftragsverarbeiter gemäß Art.&nbsp;28&nbsp;DSGVO und verwendet die Daten nicht für eigene Zwecke. Wir haben mit OpenAI einen Vertrag zur Auftragsverarbeitung geschlossen, der die EU-Standardvertragsklauseln als geeignete Garantien für den Datenschutz umfasst. OpenAI hat seinen Hauptsitz in den USA; durch die Vereinbarung der Standardvertragsklauseln wird sichergestellt, dass bei der Übermittlung Ihrer Daten ein der Europäischen Union entsprechendes Datenschutzniveau gewährleistet ist.
</Typography>
<Typography variant="body1" paragraph>
Wir speichern Ihre Chat-Anfragen nur so lange, wie es für die Bearbeitung und Beantwortung erforderlich ist. Sobald Ihr Anliegen abgeschlossen ist, werden die Chat-Verläufe zeitnah gelöscht beziehungsweise anonymisiert. OpenAI bewahrt die verarbeiteten Chat-Daten nach eigenen Angaben nur vorübergehend auf und löscht sie automatisiert spätestens nach 30&nbsp;Tagen.
</Typography>
<Typography variant="body1" paragraph>
Die Nutzung des Chatbots ist freiwillig. Wenn Sie den Chatbot nicht verwenden, findet keine Datenübermittlung an OpenAI statt. Bitte geben Sie im Chat keine sensiblen personenbezogenen Daten ein.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Cookies
</Typography>
<Typography variant="body1" paragraph>
Unsere Website setzt Cookies in folgenden Fällen ein:
</Typography>
<Typography variant="body1" paragraph component="div" style={{ paddingLeft: '20px' }}>
<div style={{ marginBottom: '10px' }}>
<strong>1. Zahlungsprozess:</strong> Bei Kreditkartenzahlungen oder Sofortüberweisungen (z.&nbsp;B. Klarna Sofort) werden technisch notwendige Cookies verwendet. Diese enthalten eine charakteristische Zeichenfolge, die eine eindeutige Identifizierung des Browsers ermöglicht. Die Cookies werden vom Zahlungsdienstleister Stripe gesetzt und sind für die sichere und reibungslose Abwicklung der Zahlungen zwingend erforderlich. Ohne diese Cookies ist eine Bestellung mit diesen Zahlungsarten nicht möglich. Die Verarbeitung erfolgt auf Grundlage des Art. 6 (1) lit. b DSGVO zur Vertragserfüllung.
</div>
<div>
<strong>2. Google Single Sign-On (SSO):</strong> Bei Nutzung der Google-Anmeldung werden Cookies durch Google gesetzt, die für den Anmeldevorgang und die Authentifizierung erforderlich sind. Diese Cookies ermöglichen es Ihnen, sich bequem mit Ihrem Google-Konto anmelden zu können, ohne sich jedes Mal neu anmelden zu müssen. Die Verarbeitung erfolgt auf Grundlage von Art. 6 (1) lit. b DSGVO (Vertragserfüllung) und Art. 6 (1) lit. f DSGVO (berechtigtes Interesse an einer benutzerfreundlichen Anmeldung).
</div>
</Typography>
<Typography variant="body1" paragraph>
Für andere Zahlungsarten Lastschrift, Abholung oder Nachnahme werden keine zusätzlichen Cookies verwendet, sofern Sie nicht die Google-Anmeldung nutzen.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Stripe (Zahlungsabwicklung)
</Typography>
<Typography variant="body1" paragraph>
Wir nutzen auf unserer Website den Zahlungsdienstleister Stripe zur Abwicklung von Zahlungen. Anbieter des Dienstes ist Stripe, Inc., 510 Townsend Street, San Francisco, CA&nbsp;94103, USA (für Kunden im Europäischen Wirtschaftsraum: Stripe Payments Europe Ltd., 1&nbsp;Grand Canal Street Lower, Dublin, Irland). In diesem Zusammenhang werden personenbezogene Daten, die für die Zahlungsabwicklung erforderlich sind, an Stripe übermittelt insbesondere Ihr Name, Ihre E-Mail-Adresse, Rechnungsanschrift, Zahlungsinformationen (z.&nbsp;B.&nbsp;Kreditkartendaten) sowie die IP-Adresse. Die Datenverarbeitung erfolgt zum Zweck der Zahlungsabwicklung; Rechtsgrundlage ist Art.&nbsp;6&nbsp;Abs.&nbsp;1&nbsp;lit.&nbsp;b&nbsp;DSGVO, da sie der Erfüllung eines Vertrags mit Ihnen dient.
</Typography>
<Typography variant="body1" paragraph>
Stripe verarbeitet bestimmte Daten außerdem als eigenständig Verantwortlicher, beispielsweise zur Erfüllung gesetzlicher Pflichten (etwa Geldwäsche-Prävention) und zur Betrugsabwehr. Daneben haben wir mit Stripe einen Auftragsverarbeitungsvertrag gemäß Art.&nbsp;28&nbsp;DSGVO geschlossen; im Rahmen dieser Vereinbarung handelt Stripe bei der Zahlungsabwicklung ausschließlich nach unserer Weisung.
</Typography>
<Typography variant="body1" paragraph>
Soweit Stripe personenbezogene Daten außerhalb der EU, insbesondere in den USA, verarbeitet, geschieht dies unter Einhaltung geeigneter Garantien. Stripe setzt hierfür die EU-Standardvertragsklauseln nach Art.&nbsp;46&nbsp;DSGVO ein, um ein angemessenes Datenschutzniveau sicherzustellen. Wir weisen jedoch darauf hin, dass die USA datenschutzrechtlich als Drittland mit möglicherweise unzureichendem Datenschutzniveau gelten. Weitere Informationen finden Sie in der Datenschutzerklärung von Stripe unter&nbsp;<a href="https://stripe.com/de/privacy" target="_blank" rel="noopener noreferrer">https://stripe.com/de/privacy</a>.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Dauer der Speicherung
</Typography>
<Typography variant="body1" paragraph>
Nach vollständiger Vertragsabwicklung werden die Daten zunächst für die Dauer der Gewährleistungsfrist, danach unter Berücksichtigung gesetzlicher, insbesondere steuer- und handelsrechtlicher Aufbewahrungsfristen gespeichert und dann nach Fristablauf gelöscht, sofern Sie der weitergehenden Verarbeitung und Nutzung nicht zugestimmt haben.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Rechte der betroffenen Person
</Typography>
<Typography variant="body1" paragraph>
Ihnen stehen bei Vorliegen der gesetzlichen Voraussetzungen folgende Rechte nach Art. 15 bis 20 DSGVO zu: Recht auf Auskunft, auf Berichtigung, auf Löschung, auf Einschränkung der Verarbeitung, auf Datenübertragbarkeit.
Außerdem steht Ihnen nach Art. 21 (1) DSGVO ein Widerspruchsrecht gegen die Verarbeitungen zu, die auf Art. 6 (1) f DSGVO beruhen, sowie gegen die Verarbeitung zum Zwecke von Direktwerbung. Kontaktieren Sie uns auf Wunsch. Die Kontaktdaten finden Sie in unserem Impressum.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Beschwerderecht bei der Aufsichtsbehörde
</Typography>
<Typography variant="body1" paragraph>
Sie haben gemäß Art. 77 DSGVO das Recht, sich bei der Aufsichtsbehörde zu beschweren, wenn Sie der Ansicht sind, dass die Verarbeitung Ihrer personenbezogenen Daten nicht rechtmäßig erfolgt.
</Typography>
</>
);
return <LegalPage title="Datenschutzerklärung" content={content} />;
};
export default Datenschutz;

View File

@@ -0,0 +1,525 @@
import React, { Component } from 'react';
import {
Container,
Paper,
Box,
Typography,
Button,
Divider,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
} from '@mui/material';
import {
ShoppingCart as ShoppingCartIcon,
} from '@mui/icons-material';
import { TentShapeSelector, ProductSelector, ExtrasSelector } from '../components/configurator/index.js';
import { tentShapes, tentSizes, lightTypes, ventilationTypes, extras } from '../data/configuratorData.js';
class GrowTentKonfigurator extends Component {
constructor(props) {
super(props);
// Try to restore state from window object
const savedState = window.growTentKonfiguratorState;
this.state = {
selectedTentShape: savedState?.selectedTentShape || '80x80',
selectedTentSize: savedState?.selectedTentSize || 'tent_80x80x160',
selectedLightType: savedState?.selectedLightType || 'led_quantum_board',
selectedVentilationType: savedState?.selectedVentilationType || 'premium_ventilation',
selectedExtras: savedState?.selectedExtras || [],
totalPrice: savedState?.totalPrice || 0
};
this.handleTentShapeSelect = this.handleTentShapeSelect.bind(this);
this.handleTentSizeSelect = this.handleTentSizeSelect.bind(this);
this.handleLightTypeSelect = this.handleLightTypeSelect.bind(this);
this.handleVentilationSelect = this.handleVentilationSelect.bind(this);
this.handleExtraToggle = this.handleExtraToggle.bind(this);
this.calculateTotalPrice = this.calculateTotalPrice.bind(this);
this.saveStateToWindow = this.saveStateToWindow.bind(this);
}
saveStateToWindow() {
// Save current state to window object for backup
window.growTentKonfiguratorState = {
selectedTentShape: this.state.selectedTentShape,
selectedTentSize: this.state.selectedTentSize,
selectedLightType: this.state.selectedLightType,
selectedVentilationType: this.state.selectedVentilationType,
selectedExtras: this.state.selectedExtras,
totalPrice: this.state.totalPrice,
timestamp: Date.now() // Add timestamp for debugging
};
}
componentDidMount() {
// @note Calculate initial total price with preselected products
this.calculateTotalPrice();
}
componentDidUpdate(prevProps, prevState) {
// Reset tent size selection if shape changes
if (prevState.selectedTentShape !== this.state.selectedTentShape && this.state.selectedTentShape !== prevState.selectedTentShape) {
this.setState({ selectedTentSize: '' });
}
// Recalculate total price when selections change
if (
prevState.selectedTentSize !== this.state.selectedTentSize ||
prevState.selectedLightType !== this.state.selectedLightType ||
prevState.selectedVentilationType !== this.state.selectedVentilationType ||
prevState.selectedExtras !== this.state.selectedExtras
) {
this.calculateTotalPrice();
}
// Save state to window object whenever selections change
if (
prevState.selectedTentShape !== this.state.selectedTentShape ||
prevState.selectedTentSize !== this.state.selectedTentSize ||
prevState.selectedLightType !== this.state.selectedLightType ||
prevState.selectedVentilationType !== this.state.selectedVentilationType ||
prevState.selectedExtras !== this.state.selectedExtras ||
prevState.totalPrice !== this.state.totalPrice
) {
this.saveStateToWindow();
}
}
handleTentShapeSelect(shapeId) {
this.setState({ selectedTentShape: shapeId });
}
handleTentSizeSelect(tentId) {
this.setState({ selectedTentSize: tentId });
}
handleLightTypeSelect(lightId) {
this.setState({ selectedLightType: lightId });
}
handleVentilationSelect(ventilationId) {
this.setState({ selectedVentilationType: ventilationId });
}
handleExtraToggle(extraId) {
const { selectedExtras } = this.state;
const newSelectedExtras = selectedExtras.includes(extraId)
? selectedExtras.filter(id => id !== extraId)
: [...selectedExtras, extraId];
this.setState({ selectedExtras: newSelectedExtras });
}
calculateTotalPrice() {
let total = 0;
const { selectedTentSize, selectedLightType, selectedVentilationType, selectedExtras } = this.state;
let itemCount = 0;
// Add tent price
if (selectedTentSize) {
const tent = tentSizes.find(t => t.id === selectedTentSize);
if (tent) {
total += tent.price;
itemCount++;
}
}
// Add light price
if (selectedLightType) {
const light = lightTypes.find(l => l.id === selectedLightType);
if (light) {
total += light.price;
itemCount++;
}
}
// Add ventilation price
if (selectedVentilationType) {
const ventilation = ventilationTypes.find(v => v.id === selectedVentilationType);
if (ventilation) {
total += ventilation.price;
itemCount++;
}
}
// Add extras prices
selectedExtras.forEach(extraId => {
const extra = extras.find(e => e.id === extraId);
if (extra) {
total += extra.price;
itemCount++;
}
});
// Apply bundle discount
let discountPercentage = 0;
if (itemCount >= 3) discountPercentage = 15; // 15% for 3+ items
if (itemCount >= 5) discountPercentage = 24; // 24% for 5+ items
if (itemCount >= 7) discountPercentage = 36; // 36% for 7+ items
const discountedTotal = total * (1 - discountPercentage / 100);
this.setState({ totalPrice: discountedTotal });
}
formatPrice(price) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(price);
}
calculateSavings() {
// Bundle discount calculation
const { selectedTentSize, selectedLightType, selectedVentilationType, selectedExtras } = this.state;
let itemCount = 0;
let originalTotal = 0;
// Calculate original total without discount
if (selectedTentSize) {
const tent = tentSizes.find(t => t.id === selectedTentSize);
if (tent) {
originalTotal += tent.price;
itemCount++;
}
}
if (selectedLightType) {
const light = lightTypes.find(l => l.id === selectedLightType);
if (light) {
originalTotal += light.price;
itemCount++;
}
}
if (selectedVentilationType) {
const ventilation = ventilationTypes.find(v => v.id === selectedVentilationType);
if (ventilation) {
originalTotal += ventilation.price;
itemCount++;
}
}
selectedExtras.forEach(extraId => {
const extra = extras.find(e => e.id === extraId);
if (extra) {
originalTotal += extra.price;
itemCount++;
}
});
// Progressive discount based on number of selected items
let discountPercentage = 0;
if (itemCount >= 3) discountPercentage = 15; // 15% for 3+ items
if (itemCount >= 5) discountPercentage = 24; // 24% for 5+ items
if (itemCount >= 7) discountPercentage = 36; // 36% for 7+ items
const savings = originalTotal * (discountPercentage / 100);
return {
savings: savings,
discountPercentage: discountPercentage,
hasDiscount: discountPercentage > 0
};
}
renderTentShapeSection() {
const { selectedTentShape } = this.state;
return (
<TentShapeSelector
tentShapes={tentShapes}
selectedShape={selectedTentShape}
onShapeSelect={this.handleTentShapeSelect}
title="1. Growbox-Form auswählen"
subtitle="Wähle zuerst die Grundfläche deiner Growbox aus"
/>
);
}
renderTentSizeSection() {
const { selectedTentSize, selectedTentShape } = this.state;
// Filter tents by selected shape
const filteredTents = tentSizes.filter(tent => tent.shapeId === selectedTentShape);
if (!selectedTentShape) {
return null; // Don't show tent sizes until shape is selected
}
return (
<ProductSelector
products={filteredTents}
selectedValue={selectedTentSize}
onSelect={this.handleTentSizeSelect}
productType="tent"
title="2. Growbox Produkt auswählen"
subtitle={`Wähle das passende Produkt für deine ${selectedTentShape} Growbox`}
gridSize={{ xs: 12, sm: 6, md: 3 }}
/>
);
}
renderLightSection() {
const { selectedLightType } = this.state;
return (
<ProductSelector
products={lightTypes}
selectedValue={selectedLightType}
onSelect={this.handleLightTypeSelect}
productType="light"
title="3. Beleuchtung wählen"
gridSize={{ xs: 12, sm: 6 }}
/>
);
}
renderVentilationSection() {
const { selectedVentilationType } = this.state;
return (
<ProductSelector
products={ventilationTypes}
selectedValue={selectedVentilationType}
onSelect={this.handleVentilationSelect}
productType="ventilation"
title="4. Belüftung auswählen"
gridSize={{ xs: 12, md: 4 }}
/>
);
}
renderExtrasSection() {
const { selectedExtras } = this.state;
return (
<ExtrasSelector
extras={extras}
selectedExtras={selectedExtras}
onExtraToggle={this.handleExtraToggle}
title="5. Extras hinzufügen (optional)"
groupByCategory={true}
gridSize={{ xs: 12, sm: 6, md: 4 }}
/>
);
}
renderInlineSummary() {
const { selectedTentSize, selectedLightType, selectedVentilationType, selectedExtras, totalPrice } = this.state;
const selectedTent = tentSizes.find(t => t.id === selectedTentSize);
const selectedLight = lightTypes.find(l => l.id === selectedLightType);
const selectedVentilation = ventilationTypes.find(v => v.id === selectedVentilationType);
const selectedExtrasItems = extras.filter(e => selectedExtras.includes(e.id));
const savingsInfo = this.calculateSavings();
return (
<Paper
id="inline-summary" // @note Add ID for scroll targeting
elevation={2}
sx={{
mt: 4,
p: 3,
bgcolor: '#f8f9fa',
border: '2px solid #2e7d32',
borderRadius: 2
}}
>
<Typography variant="h5" sx={{ color: '#2e7d32', fontWeight: 'bold', mb: 3, textAlign: 'center' }}>
🎯 Ihre Konfiguration
</Typography>
<List sx={{ '& .MuiListItem-root': { py: 1 } }}>
{selectedTent && (
<ListItem>
<ListItemText
primary={`Growbox: ${selectedTent.name}`}
secondary={selectedTent.description}
/>
<ListItemSecondaryAction>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{this.formatPrice(selectedTent.price)}
</Typography>
</ListItemSecondaryAction>
</ListItem>
)}
{selectedLight && (
<ListItem>
<ListItemText
primary={`Beleuchtung: ${selectedLight.name}`}
secondary={selectedLight.description}
/>
<ListItemSecondaryAction>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{this.formatPrice(selectedLight.price)}
</Typography>
</ListItemSecondaryAction>
</ListItem>
)}
{selectedVentilation && (
<ListItem>
<ListItemText
primary={`Belüftung: ${selectedVentilation.name}`}
secondary={selectedVentilation.description}
/>
<ListItemSecondaryAction>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{this.formatPrice(selectedVentilation.price)}
</Typography>
</ListItemSecondaryAction>
</ListItem>
)}
{selectedExtrasItems.map(extra => (
<ListItem key={extra.id}>
<ListItemText
primary={`Extra: ${extra.name}`}
secondary={extra.description}
/>
<ListItemSecondaryAction>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{this.formatPrice(extra.price)}
</Typography>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
<Divider sx={{ my: 3 }} />
{savingsInfo.hasDiscount && (
<Box sx={{ mb: 2, textAlign: 'center' }}>
<Typography variant="h6" sx={{ color: '#d32f2f', fontWeight: 'bold' }}>
Sie sparen: {this.formatPrice(savingsInfo.savings)} ({savingsInfo.discountPercentage}% Bundle-Rabatt)
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
Gesamtpreis:
</Typography>
<Typography variant="h4" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
{this.formatPrice(totalPrice)}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
<Button
variant="contained"
size="large"
startIcon={<ShoppingCartIcon />}
sx={{
bgcolor: '#2e7d32',
'&:hover': { bgcolor: '#1b5e20' },
minWidth: 250
}}
>
In den Warenkorb
</Button>
</Box>
</Paper>
);
}
render() {
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<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>
{this.renderTentShapeSection()}
<Divider sx={{ my: 4 }} />
{this.renderTentSizeSection()}
{this.state.selectedTentShape && <Divider sx={{ my: 4 }} />}
{this.renderLightSection()}
<Divider sx={{ my: 4 }} />
{this.renderVentilationSection()}
<Divider sx={{ my: 4 }} />
{this.renderExtrasSection()}
{/* Inline summary section - expands when scrolling to bottom */}
{this.renderInlineSummary()}
</Paper>
</Container>
);
}
}
export default GrowTentKonfigurator;

651
src/pages/Home.js Normal file
View File

@@ -0,0 +1,651 @@
import React, { useState, useEffect, useContext, useRef } from "react";
import Container from "@mui/material/Container";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
import Grid from "@mui/material/Grid";
import IconButton from "@mui/material/IconButton";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import { Link } from "react-router-dom";
import CategoryBox from "../components/CategoryBox.js";
import SocketContext from "../contexts/SocketContext.js";
import { getCombinedAnimatedBorderStyles } from "../utils/animatedBorderStyles.js";
// @note SwashingtonCP font is now loaded globally via index.css
// Carousel styles - Simple styles for JavaScript-based animation
const carouselStyles = `
.carousel-wrapper {
position: relative;
overflow: hidden;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
z-index: 1;
}
.carousel-wrapper .carousel-container {
position: relative;
overflow: hidden;
padding: 20px 0;
width: 100%;
max-width: 1080px;
margin: 0 auto;
z-index: 1;
}
.carousel-wrapper .home-carousel-track {
display: flex;
gap: 16px;
transition: none;
align-items: flex-start;
width: 1200px;
max-width: 100%;
overflow: visible;
position: relative;
z-index: 1;
}
.carousel-wrapper .carousel-item {
flex: 0 0 130px;
width: 130px !important;
max-width: 130px;
min-width: 130px;
height: 130px !important;
max-height: 130px;
min-height: 130px;
box-sizing: border-box;
position: relative;
z-index: 2;
}
.carousel-nav-button {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 20;
background-color: rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
width: 48px;
height: 48px;
}
.carousel-nav-button:hover {
background-color: rgba(255, 255, 255, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.carousel-nav-left {
left: 8px;
}
.carousel-nav-right {
right: 8px;
}
`;
// Generate combined styles for both seeds and cutlings cards
const animatedBorderStyle = getCombinedAnimatedBorderStyles([
"seeds",
"cutlings",
]);
const Home = () => {
const carouselRef = useRef(null);
const scrollPositionRef = useRef(0);
const animationIdRef = useRef(null);
const isPausedRef = useRef(false);
const resumeTimeoutRef = useRef(null);
// @note Initialize refs properly
useEffect(() => {
isPausedRef.current = false;
scrollPositionRef.current = 0;
}, []);
// Helper to process and set categories
const processCategoryTree = (categoryTree) => {
if (
categoryTree &&
categoryTree.id === 209 &&
Array.isArray(categoryTree.children)
) {
return categoryTree.children;
} else {
return [];
}
};
// Check for cached data - handle both browser and prerender environments
const getProductCache = () => {
if (typeof window !== "undefined" && window.productCache) {
return window.productCache;
}
if (
typeof global !== "undefined" &&
global.window &&
global.window.productCache
) {
return global.window.productCache;
}
return null;
};
// Initialize rootCategories from cache if available (for prerendering)
const initializeCategories = () => {
const productCache = getProductCache();
if (productCache && productCache["categoryTree_209"]) {
const cached = productCache["categoryTree_209"];
//const cacheAge = Date.now() - cached.timestamp;
//const tenMinutes = 10 * 60 * 1000;
if (/*cacheAge < tenMinutes &&*/ cached.categoryTree) {
return processCategoryTree(cached.categoryTree);
}
}
return [];
};
const [rootCategories, setRootCategories] = useState(() =>
initializeCategories()
);
const socket = useContext(SocketContext);
useEffect(() => {
// Only fetch from socket if we don't already have categories and we're in browser
if (
rootCategories.length === 0 &&
socket &&
typeof window !== "undefined"
) {
socket.emit("categoryList", { categoryId: 209 }, (response) => {
if (response && response.categoryTree) {
// Store in cache
try {
if (!window.productCache) window.productCache = {};
window.productCache["categoryTree_209"] = {
categoryTree: response.categoryTree,
timestamp: Date.now(),
};
} catch (err) {
console.error(err);
}
setRootCategories(response.categoryTree.children || []);
}
});
}
}, [socket, rootCategories.length]);
// Filter categories (excluding specific IDs)
const filteredCategories = rootCategories.filter(
(cat) => cat.id !== 689 && cat.id !== 706
);
// Create duplicated array for seamless scrolling
const displayCategories = [...filteredCategories, ...filteredCategories];
// Auto-scroll effect
useEffect(() => {
if (filteredCategories.length === 0) return;
// @note Add a small delay to ensure DOM is ready after prerender
const startAnimation = () => {
if (!carouselRef.current) {
return false;
}
// @note Reset paused state when starting animation
isPausedRef.current = false;
const itemWidth = 146; // 130px + 16px gap
const totalWidth = filteredCategories.length * itemWidth;
const animate = () => {
// Check if we should be animating
if (!animationIdRef.current || isPausedRef.current) {
return;
}
scrollPositionRef.current += 0.5; // Speed of scrolling
// Reset position for seamless loop
if (scrollPositionRef.current >= totalWidth) {
scrollPositionRef.current = 0;
}
if (carouselRef.current) {
const transform = `translateX(-${scrollPositionRef.current}px)`;
carouselRef.current.style.transform = transform;
}
animationIdRef.current = requestAnimationFrame(animate);
};
// Only start animation if not paused
if (!isPausedRef.current) {
animationIdRef.current = requestAnimationFrame(animate);
return true;
}
return false;
};
// Try immediately, then with increasing delays to handle prerender scenarios
if (!startAnimation()) {
const timeout1 = setTimeout(() => {
if (!startAnimation()) {
const timeout2 = setTimeout(() => {
if (!startAnimation()) {
const timeout3 = setTimeout(startAnimation, 2000);
return () => clearTimeout(timeout3);
}
}, 1000);
return () => clearTimeout(timeout2);
}
}, 100);
return () => {
isPausedRef.current = true;
clearTimeout(timeout1);
if (animationIdRef.current) {
cancelAnimationFrame(animationIdRef.current);
}
if (resumeTimeoutRef.current) {
clearTimeout(resumeTimeoutRef.current);
}
};
}
return () => {
isPausedRef.current = true;
if (animationIdRef.current) {
cancelAnimationFrame(animationIdRef.current);
}
if (resumeTimeoutRef.current) {
clearTimeout(resumeTimeoutRef.current);
}
};
}, [filteredCategories]);
// Additional effect to handle cases where categories are available but ref wasn't ready
useEffect(() => {
if (filteredCategories.length > 0 && carouselRef.current && !animationIdRef.current) {
// @note Reset paused state when starting animation
isPausedRef.current = false;
const itemWidth = 146;
const totalWidth = filteredCategories.length * itemWidth;
const animate = () => {
if (!animationIdRef.current || isPausedRef.current) {
return;
}
scrollPositionRef.current += 0.5;
if (scrollPositionRef.current >= totalWidth) {
scrollPositionRef.current = 0;
}
if (carouselRef.current) {
const transform = `translateX(-${scrollPositionRef.current}px)`;
carouselRef.current.style.transform = transform;
}
animationIdRef.current = requestAnimationFrame(animate);
};
if (!isPausedRef.current) {
animationIdRef.current = requestAnimationFrame(animate);
}
}
});
// Manual navigation
const moveCarousel = (direction) => {
if (!carouselRef.current) return;
// Pause auto-scroll
isPausedRef.current = true;
if (animationIdRef.current) {
cancelAnimationFrame(animationIdRef.current);
animationIdRef.current = null;
}
const itemWidth = 146;
const moveAmount = itemWidth * 3; // Move 3 items at a time
const totalWidth = filteredCategories.length * itemWidth;
if (direction === "left") {
scrollPositionRef.current -= moveAmount;
// Handle wrapping for infinite scroll
if (scrollPositionRef.current < 0) {
scrollPositionRef.current = totalWidth + scrollPositionRef.current;
}
} else {
scrollPositionRef.current += moveAmount;
// Handle wrapping for infinite scroll
if (scrollPositionRef.current >= totalWidth) {
scrollPositionRef.current = scrollPositionRef.current % totalWidth;
}
}
// Apply smooth transition for manual navigation
carouselRef.current.style.transition = "transform 0.5s ease-in-out";
carouselRef.current.style.transform = `translateX(-${scrollPositionRef.current}px)`;
// Remove transition after animation completes
setTimeout(() => {
if (carouselRef.current) {
carouselRef.current.style.transition = "none";
}
}, 500);
// Clear any existing resume timeout
if (resumeTimeoutRef.current) {
clearTimeout(resumeTimeoutRef.current);
}
// Resume auto-scroll after 3 seconds
resumeTimeoutRef.current = setTimeout(() => {
isPausedRef.current = false;
const animate = () => {
if (!animationIdRef.current || isPausedRef.current) {
return;
}
scrollPositionRef.current += 0.5;
if (scrollPositionRef.current >= totalWidth) {
scrollPositionRef.current = 0;
}
if (carouselRef.current) {
carouselRef.current.style.transform = `translateX(-${scrollPositionRef.current}px)`;
}
animationIdRef.current = requestAnimationFrame(animate);
};
animationIdRef.current = requestAnimationFrame(animate);
}, 3000);
};
return (
<Container maxWidth="lg" sx={{ pt: 4, pb: 2, maxWidth: '1200px !important' }}>
{/* Inject the animated border and carousel styles */}
<style>{animatedBorderStyle}</style>
<style>{carouselStyles}</style>
<Typography
variant="h3"
component="h1"
sx={{
mb: 4,
fontFamily: "SwashingtonCP",
color: "primary.main",
textAlign: "center",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
}}
>
ine annabis eeds & uttings
</Typography>
<Grid container sx={{ display: "flex", flexDirection: "row" }}>
{/* Seeds Category Box */}
<Grid item xs={12} sm={6} sx={{ p: 2, width: "50%" }}>
<div className="animated-border-card seeds-card">
<Paper
component={Link}
to="/Kategorie/Seeds"
sx={{
p: 0,
textDecoration: "none",
color: "text.primary",
borderRadius: 2,
overflow: "hidden",
height: { xs: 250, sm: 300 },
display: "flex",
flexDirection: "column",
transition: "all 0.3s ease",
boxShadow: 10,
"&: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: 1,
}}
>
<Typography
sx={{
fontSize: "1.6rem",
color: "white",
fontFamily: "SwashingtonCP",
}}
>
Seeds
</Typography>
</Box>
</Box>
</Paper>
</div>
</Grid>
{/* Cutlings Category Box */}
<Grid item xs={12} sm={6} sx={{ p: 2, width: "50%" }}>
<div className="animated-border-card cutlings-card">
<Paper
component={Link}
to="/Kategorie/Stecklinge"
sx={{
p: 0,
textDecoration: "none",
color: "text.primary",
borderRadius: 2,
overflow: "hidden",
height: { xs: 250, sm: 300 },
display: "flex",
flexDirection: "column",
boxShadow: 10,
transition: "all 0.3s ease",
"&: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: 1,
}}
>
<Typography
sx={{
fontSize: "1.6rem",
color: "white",
fontFamily: "SwashingtonCP",
}}
>
Stecklinge
</Typography>
</Box>
</Box>
</Paper>
</div>
</Grid>
</Grid>
{/* Continuous Rotating Carousel for Categories */}
<Box sx={{ mt: 3 }}>
<Typography
variant="h4"
component="h1"
sx={{
mb: 2,
fontFamily: "SwashingtonCP",
color: "primary.main",
textAlign: "center",
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
}}
>
Kategorien
</Typography>
{filteredCategories.length > 0 && (
<div
className="carousel-wrapper"
style={{
position: 'relative',
overflow: 'hidden',
width: '100%',
maxWidth: '1200px',
margin: '0 auto',
padding: '0 20px',
boxSizing: 'border-box'
}}
>
{/* Left Arrow */}
<IconButton
onClick={() => moveCarousel("left")}
aria-label="Vorherige Kategorien anzeigen"
style={{
position: 'absolute',
top: '50%',
left: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronLeft />
</IconButton>
{/* Right Arrow */}
<IconButton
onClick={() => moveCarousel("right")}
aria-label="Nächste Kategorien anzeigen"
style={{
position: 'absolute',
top: '50%',
right: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
height: '48px',
borderRadius: '50%'
}}
>
<ChevronRight />
</IconButton>
<div
className="carousel-container"
style={{
position: 'relative',
overflow: 'hidden',
padding: '20px 0',
width: '100%',
maxWidth: '1080px',
margin: '0 auto',
zIndex: 1,
boxSizing: 'border-box'
}}
>
<div
className="home-carousel-track"
ref={carouselRef}
style={{
display: 'flex',
gap: '16px',
transition: 'none',
alignItems: 'flex-start',
width: 'fit-content',
overflow: 'visible',
position: 'relative',
transform: 'translateX(0px)',
margin: '0 auto'
}}
>
{displayCategories.map((category, index) => (
<div
key={`${category.id}-${index}`}
className="carousel-item"
style={{
flex: '0 0 130px',
width: '130px',
maxWidth: '130px',
minWidth: '130px',
height: '130px',
maxHeight: '130px',
minHeight: '130px',
boxSizing: 'border-box',
position: 'relative'
}}
>
<CategoryBox
id={category.id}
name={category.name}
seoName={category.seoName}
image={category.image}
bgcolor={category.bgcolor}
/>
</div>
))}
</div>
</div>
</div>
)}
</Box>
</Container>
);
};
export default Home;

52
src/pages/Impressum.js Normal file
View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Typography } from '@mui/material';
import LegalPage from './LegalPage.js';
const Impressum = () => {
const content = (
<>
<Typography variant="h6" gutterBottom>
Betreiber und verantwortlich für die Inhalte dieses Shops ist:
</Typography>
<Typography variant="body1" paragraph>
Growheads<br />
Max Schön<br />
Trachenberger Straße 14<br />
01129 Dresden
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Kontakt:
</Typography>
<Typography variant="body1" paragraph>
E-Mail: service@growheads.de
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Umsatzsteuer-ID:
</Typography>
<Typography variant="body1" paragraph>
USt.-IdNr.: DE323017152
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Haftungsausschluss:
</Typography>
<Typography variant="body1" paragraph>
Für Inhalte von auf diesen Seiten verlinkten externen Internetadressen übernehmen wir keine Haftung. Für Inhalte betriebsfremder Domizile sind die jeweiligen Betreiber verantwortlich.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Urheberrechtsklausel:
</Typography>
<Typography variant="body1" paragraph>
Die hier dargestellten Inhalte unterliegen grundsätzlich dem Urheberrecht und dürfen nur mit schriftlicher Genehmigung verbreitet werden.
Die Rechte an Foto- oder Textmaterial von anderen Parteien sind durch diese Klausel weder eingeschränkt noch aufgehoben.
</Typography>
</>
);
return <LegalPage title="Impressum" content={content} />;
};
export default Impressum;

22
src/pages/LegalPage.js Normal file
View File

@@ -0,0 +1,22 @@
import React from 'react';
import Container from '@mui/material/Container';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
const LegalPage = ({ title, content }) => {
return (
<Container maxWidth="md" sx={{ py: 6 }}>
<Paper sx={{ p: 4, borderRadius: 2 }}>
<Typography variant="h4" component="h1" gutterBottom>
{title}
</Typography>
<Box sx={{ mt: 3 }}>
{content}
</Box>
</Paper>
</Container>
);
};
export default LegalPage;

232
src/pages/ProfilePage.js Normal file
View File

@@ -0,0 +1,232 @@
import React, { useState, useEffect, useContext } from 'react';
import {
Container,
Paper,
Box,
Typography,
Tabs,
Tab,
CircularProgress
} from '@mui/material';
import { useLocation, useNavigate, Navigate } from 'react-router-dom';
import SocketContext from '../contexts/SocketContext.js';
// Import extracted components
import OrdersTab from '../components/profile/OrdersTab.js';
import SettingsTab from '../components/profile/SettingsTab.js';
import CartTab from '../components/profile/CartTab.js';
import LoginComponent from '../components/LoginComponent.js';
// Functional Profile Page Component
const ProfilePage = (props) => {
const location = useLocation();
const navigate = useNavigate();
const [tabValue, setTabValue] = useState(0);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [showLogin, setShowLogin] = useState(false);
const [orderIdFromHash, setOrderIdFromHash] = useState(null);
const [paymentCompletion, setPaymentCompletion] = useState(null);
// @note Check for payment completion parameters from Stripe redirect
useEffect(() => {
const urlParams = new URLSearchParams(location.search);
const isComplete = urlParams.has('complete');
const paymentIntent = urlParams.get('payment_intent');
const paymentIntentClientSecret = urlParams.get('payment_intent_client_secret');
const redirectStatus = urlParams.get('redirect_status');
if (isComplete && paymentIntent && redirectStatus) {
setPaymentCompletion({
paymentIntent,
paymentIntentClientSecret,
redirectStatus,
isSuccessful: redirectStatus === 'succeeded'
});
// Clean up the URL by removing the payment parameters
const newUrl = new URL(window.location);
newUrl.searchParams.delete('complete');
newUrl.searchParams.delete('payment_intent');
newUrl.searchParams.delete('payment_intent_client_secret');
newUrl.searchParams.delete('redirect_status');
window.history.replaceState({}, '', newUrl.toString());
}
}, [location.search]);
useEffect(() => {
const hash = location.hash;
switch (hash) {
case '#cart':
setTabValue(0);
setOrderIdFromHash(null);
break;
case '#orders':
setTabValue(1);
setOrderIdFromHash(null);
break;
case '#settings':
setTabValue(2);
setOrderIdFromHash(null);
break;
default:
if (hash && hash.startsWith('#ORD-')) {
const orderId = hash.substring(1);
setOrderIdFromHash(orderId);
setTabValue(1); // Switch to Orders tab
} else {
setOrderIdFromHash(null);
}
}
}, [location.hash]);
useEffect(() => {
const checkUserLoggedIn = () => {
const storedUser = sessionStorage.getItem('user');
if (!storedUser) {
setShowLogin(true);
setUser(null);
return;
}
try {
const userData = JSON.parse(storedUser);
if (!userData) {
setShowLogin(true);
setUser(null);
} else if (!user) {
setUser(userData);
setShowLogin(false); // Hide login on successful user load
}
} catch (error) {
console.error('Error parsing user from sessionStorage:', error);
setShowLogin(true);
setUser(null);
}
if (loading) {
setLoading(false);
}
};
checkUserLoggedIn();
const checkLoginInterval = setInterval(checkUserLoggedIn, 1000);
const handleStorageChange = (e) => {
if (e.key === 'user' && !e.newValue) {
// User was removed from sessionStorage in another tab
setShowLogin(true);
setUser(null);
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
clearInterval(checkLoginInterval);
window.removeEventListener('storage', handleStorageChange);
};
}, [user, loading]);
const handleTabChange = (event, newValue) => {
setTabValue(newValue);
};
const handleGoToOrders = () => {
setTabValue(1);
};
const handleClearPaymentCompletion = () => {
setPaymentCompletion(null);
};
const handleLoginClose = () => {
setShowLogin(false);
// If user closes login without logging in, redirect to home.
const storedUser = sessionStorage.getItem('user');
if (!storedUser) {
navigate('/', { replace: true });
}
}
if (showLogin) {
return <LoginComponent open={showLogin} handleClose={handleLoginClose} location={location} socket={props.socket} />;
}
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 5 }}>
<CircularProgress />
</Box>
);
}
if (!user) {
return <Navigate to="/" replace />;
}
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<Paper elevation={2} sx={{ borderRadius: 2, overflow: 'hidden' }}>
<Box sx={{ bgcolor: '#2e7d32', p: 3, color: 'white' }}>
<Typography variant="h5" fontWeight="bold">
Mein Profil
</Typography>
{user && (
<Typography variant="body1" sx={{ mt: 1 }}>
{user.email}
</Typography>
)}
</Box>
<Box>
<Tabs
value={tabValue}
onChange={handleTabChange}
variant="fullWidth"
sx={{ borderBottom: 1, borderColor: 'divider' }}
TabIndicatorProps={{
style: { backgroundColor: '#2e7d32' }
}}
>
<Tab
label="Bestellabschluss"
sx={{
color: tabValue === 0 ? '#2e7d32' : 'inherit',
fontWeight: 'bold'
}}
/>
<Tab
label="Bestellungen"
sx={{
color: tabValue === 1 ? '#2e7d32' : 'inherit',
fontWeight: 'bold'
}}
/>
<Tab
label="Einstellungen"
sx={{
color: tabValue === 2 ? '#2e7d32' : 'inherit',
fontWeight: 'bold'
}}
/>
</Tabs>
{tabValue === 0 && <CartTab onOrderSuccess={handleGoToOrders} paymentCompletion={paymentCompletion} onClearPaymentCompletion={handleClearPaymentCompletion} />}
{tabValue === 1 && <OrdersTab orderIdFromHash={orderIdFromHash} />}
{tabValue === 2 && <SettingsTab socket={props.socket} />}
</Box>
</Paper>
</Container>
);
};
// Wrap with socket context
const ProfilePageWithSocket = (props) => {
const socket = useContext(SocketContext);
return <ProfilePage {...props} socket={socket} />;
};
export default ProfilePageWithSocket;

176
src/pages/ResetPassword.js Normal file
View File

@@ -0,0 +1,176 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Container,
Paper,
TextField,
Button,
Typography,
Box,
Alert,
CircularProgress
} from '@mui/material';
import LockResetIcon from '@mui/icons-material/LockReset';
const ResetPassword = ({ socket }) => {
const navigate = useNavigate();
const location = useLocation();
const [token, setToken] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
useEffect(() => {
// Extract token from URL query parameters
const urlParams = new URLSearchParams(location.search);
const tokenFromUrl = urlParams.get('token');
if (!tokenFromUrl) {
setError('Kein gültiger Token gefunden. Bitte verwenden Sie den Link aus Ihrer E-Mail.');
} else {
setToken(tokenFromUrl);
}
}, [location]);
const handleSubmit = (e) => {
e.preventDefault();
// Validation
if (!newPassword || !confirmPassword) {
setError('Bitte füllen Sie alle Felder aus');
return;
}
if (newPassword.length < 8) {
setError('Das Passwort muss mindestens 8 Zeichen lang sein');
return;
}
if (newPassword !== confirmPassword) {
setError('Die Passwörter stimmen nicht überein');
return;
}
setLoading(true);
setError('');
// Emit verifyResetToken event
socket.emit('verifyResetToken', {
token: token,
newPassword: newPassword
}, (response) => {
setLoading(false);
if (response.success) {
setSuccess(true);
// Redirect to login after 3 seconds
setTimeout(() => {
navigate('/');
// Open login drawer if available
if (window.openLoginDrawer) {
window.openLoginDrawer();
}
}, 3000);
} else {
setError(response.message || 'Fehler beim Zurücksetzen des Passworts');
}
});
};
return (
<Container maxWidth="sm" sx={{ mt: 8, mb: 4 }}>
<Paper elevation={3} sx={{ p: 4 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<LockResetIcon sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography component="h1" variant="h5" gutterBottom>
Passwort zurücksetzen
</Typography>
{!token ? (
<Alert severity="error" sx={{ mt: 2, width: '100%' }}>
{error}
</Alert>
) : success ? (
<Box sx={{ width: '100%', mt: 2 }}>
<Alert severity="success" sx={{ mb: 2 }}>
Ihr Passwort wurde erfolgreich zurückgesetzt! Sie werden in Kürze zur Anmeldung weitergeleitet...
</Alert>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={24} />
</Box>
</Box>
) : (
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2, width: '100%' }}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
margin="normal"
required
fullWidth
name="newPassword"
label="Neues Passwort"
type="password"
id="newPassword"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={loading}
/>
<TextField
margin="normal"
required
fullWidth
name="confirmPassword"
label="Passwort bestätigen"
type="password"
id="confirmPassword"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={loading}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{
mt: 3,
mb: 2,
bgcolor: '#2e7d32',
'&:hover': { bgcolor: '#1b5e20' }
}}
disabled={loading}
>
{loading ? (
<CircularProgress size={24} color="inherit" />
) : (
'Passwort zurücksetzen'
)}
</Button>
<Box sx={{ textAlign: 'center' }}>
<Button
variant="text"
onClick={() => navigate('/')}
sx={{ color: '#2e7d32' }}
>
Zurück zur Startseite
</Button>
</Box>
</Box>
)}
</Box>
</Paper>
</Container>
);
};
export default ResetPassword;

400
src/pages/ServerLogsPage.js Normal file
View File

@@ -0,0 +1,400 @@
import React from 'react';
import {
Container,
Typography,
Paper,
Card,
CardContent,
List,
ListItem,
Tabs,
Tab,
Stack,
Button,
Box
} from '@mui/material';
import { Navigate, Link } from 'react-router-dom';
import ArticleIcon from '@mui/icons-material/Article';
import { ADMIN_COLORS, getAdminStyles } from '../theme/adminColors.js';
class ServerLogsPage extends React.Component {
constructor(props) {
super(props);
this.state = {
user: null,
loading: true,
redirect: false,
logs: [],
historicalLogsLoaded: 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 });
}
}
handleLogEntry = (logEntry) => {
console.log(`[${logEntry.timestamp}] ${logEntry.level.toUpperCase()}: ${logEntry.message}`);
this.setState(prevState => ({
logs: [logEntry, ...prevState.logs].slice(0, 250) // Keep only last 250 logs
}));
}
parseHistoricalLogLine = (logLine) => {
// Try to parse JSON formatted log entries first
try {
const parsed = JSON.parse(logLine);
if (parsed.level && parsed.message && parsed.timestamp) {
return {
timestamp: parsed.timestamp,
level: parsed.level.toLowerCase(),
message: parsed.message
};
}
} catch {
// Not JSON, try other formats
}
// Try to parse bracket format like: "[2024-01-01T12:00:00.000Z] INFO: message content"
const match = logLine.match(/^\[(.+?)\]\s+(\w+):\s*(.*)$/);
if (match) {
return {
timestamp: match[1],
level: match[2].toLowerCase(),
message: match[3]
};
}
// Fallback for lines that don't match expected format
return {
timestamp: new Date().toISOString(),
level: 'info',
message: logLine
};
}
loadHistoricalLogs = () => {
if (this.props.socket && this.props.socket.connected) {
this.props.socket.emit('getLog', (response) => {
if (response.success) {
console.log('Last 50 historical logs:', response.data.lines);
const historicalLogs = (response.data.lines || [])
.map(line => this.parseHistoricalLogLine(line))
.reverse(); // Reverse to have newest first
this.setState(prevState => ({
logs: [...historicalLogs, ...prevState.logs].slice(0, 250),
historicalLogsLoaded: true
}));
} else {
console.warn('Failed to load historical logs:', response);
this.setState({ historicalLogsLoaded: true }); // Mark as attempted even if failed
}
});
}
}
componentDidMount() {
this.addSocketListeners();
this.checkUserLoggedIn();
this.loadHistoricalLogs(); // Load historical logs on mount
// 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();
if (!this.state.historicalLogsLoaded) {
this.loadHistoricalLogs();
}
} 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);
}
addSocketListeners = () => {
if (this.props.socket && this.props.socket.connected) {
// Remove existing listeners first to avoid duplicates
this.removeSocketListeners();
this.props.socket.on('log', this.handleLogEntry);
}
}
removeSocketListeners = () => {
if (this.props.socket) {
this.props.socket.off('log', this.handleLogEntry);
}
}
formatLogLevel = (level) => {
const colors = {
error: ADMIN_COLORS.error,
warn: ADMIN_COLORS.warning,
info: ADMIN_COLORS.primary,
debug: ADMIN_COLORS.secondary,
verbose: ADMIN_COLORS.secondaryText
};
return colors[level.toLowerCase()] || ADMIN_COLORS.primaryText;
}
clearLogs = () => {
this.setState({ logs: [] });
}
render() {
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 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={2}
indicatorColor="primary"
sx={{
px: 2,
'& .MuiTabs-indicator': {
backgroundColor: ADMIN_COLORS.primary
}
}}
>
<Tab
label="Dashboard"
component={Link}
to="/admin"
sx={{
textTransform: 'none',
color: ADMIN_COLORS.primaryText,
fontFamily: ADMIN_COLORS.fontFamily,
'&:hover': {
color: ADMIN_COLORS.primaryBright
}
}}
/>
<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',
fontWeight: 'bold',
color: ADMIN_COLORS.primary,
fontFamily: ADMIN_COLORS.fontFamily
}}
/>
</Tabs>
</Paper>
{/* Server Logs Content */}
<Paper
elevation={3}
sx={{
p: 3,
mb: 4,
...styles.contentPaper
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography
variant="h5"
sx={{
display: 'flex',
alignItems: 'center',
...styles.primaryHeading
}}
>
<ArticleIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
Server Logs
</Typography>
<Button
variant="outlined"
onClick={this.clearLogs}
size="small"
sx={{
color: ADMIN_COLORS.warning,
borderColor: ADMIN_COLORS.warning,
fontFamily: ADMIN_COLORS.fontFamily,
textTransform: 'none',
'&:hover': {
backgroundColor: ADMIN_COLORS.warning,
color: ADMIN_COLORS.hoverBackground,
borderColor: ADMIN_COLORS.warning
}
}}
>
Clear Logs
</Button>
</Box>
{this.state.logs.length === 0 ? (
<Typography
variant="body2"
sx={{
...styles.secondaryText,
p: 2
}}
>
{!this.state.historicalLogsLoaded
? "Loading historical logs..."
: "No logs available. New logs will appear here in real-time."}
</Typography>
) : (
<Card
variant="outlined"
sx={{
...styles.card,
borderRadius: 1
}}
>
<CardContent sx={{ p: 0 }}>
<List dense sx={{ p: 0 }}>
{this.state.logs.map((log, index) => (
<ListItem
key={index}
sx={{
py: 0.25,
px: 1,
borderBottom: index < this.state.logs.length - 1 ? `1px solid ${ADMIN_COLORS.hoverBackground}` : 'none',
'&:hover': {
backgroundColor: ADMIN_COLORS.hoverBackground
}
}}
>
<Stack direction="row" spacing={1} alignItems="flex-start" sx={{ width: '100%', fontFamily: ADMIN_COLORS.fontFamily }}>
<Typography
variant="caption"
sx={{
color: ADMIN_COLORS.secondaryText,
minWidth: 160,
fontSize: '0.75rem',
fontFamily: ADMIN_COLORS.fontFamily,
flexShrink: 0
}}
>
{log.timestamp}
</Typography>
<Typography
variant="caption"
sx={{
color: this.formatLogLevel(log.level),
minWidth: 60,
fontSize: '0.75rem',
fontFamily: ADMIN_COLORS.fontFamily,
fontWeight: 'bold',
flexShrink: 0,
textTransform: 'uppercase'
}}
>
{log.level}:
</Typography>
<Typography
variant="body2"
sx={{
flexGrow: 1,
wordBreak: 'break-word',
color: ADMIN_COLORS.primaryText,
fontSize: '0.875rem',
fontFamily: ADMIN_COLORS.fontFamily,
lineHeight: 1.4
}}
>
{log.message}
</Typography>
</Stack>
</ListItem>
))}
</List>
</CardContent>
</Card>
)}
</Paper>
</Container>
</Box>
);
}
}
export default ServerLogsPage;

171
src/pages/Sitemap.js Normal file
View File

@@ -0,0 +1,171 @@
import React, { useState, useEffect, useContext } from 'react';
import Typography from '@mui/material/Typography';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { Link as RouterLink } from 'react-router-dom';
import LegalPage from './LegalPage.js';
import SocketContext from '../contexts/SocketContext.js';
// Helper function to recursively collect all categories from the tree
const collectAllCategories = (categoryNode, categories = [], level = 0) => {
if (!categoryNode) return categories;
// Add current category (skip root category 209)
if (categoryNode.id !== 209 && categoryNode.seoName) {
categories.push({
id: categoryNode.id,
name: categoryNode.name,
seoName: categoryNode.seoName,
level: level
});
}
// Recursively add children
if (categoryNode.children) {
for (const child of categoryNode.children) {
collectAllCategories(child, categories, level + 1);
}
}
return categories;
};
const Sitemap = () => {
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const socket = useContext(SocketContext);
const sitemapLinks = [
{ title: 'Startseite', url: '/' },
{ title: 'Mein Profil', url: '/profile' },
{ title: 'Datenschutz', url: '/datenschutz' },
{ title: 'AGB', url: '/agb' },
{ title: 'Impressum', url: '/impressum' },
{ title: 'Batteriegesetzhinweise', url: '/batteriegesetzhinweise' },
{ title: 'Widerrufsrecht', url: '/widerrufsrecht' },
{ title: 'Growbox Konfigurator', url: '/Konfigurator' },
{ title: 'API', url: '/api/', route:false },
];
useEffect(() => {
const fetchCategories = () => {
// Try cache first
if (window.productCache && window.productCache['categoryTree_209']) {
const cached = window.productCache['categoryTree_209'];
const cacheAge = Date.now() - cached.timestamp;
const tenMinutes = 10 * 60 * 1000;
if (cacheAge < tenMinutes && cached.categoryTree) {
const allCategories = collectAllCategories(cached.categoryTree);
setCategories(allCategories);
setLoading(false);
return;
}
}
// Otherwise, fetch from socket if available
if (socket) {
socket.emit('categoryList', { categoryId: 209 }, (response) => {
if (response && response.categoryTree) {
// Store in cache
try {
if (!window.productCache) window.productCache = {};
window.productCache['categoryTree_209'] = {
categoryTree: response.categoryTree,
timestamp: Date.now()
};
} catch (err) {
console.error('Error caching category data:', err);
}
const allCategories = collectAllCategories(response.categoryTree);
setCategories(allCategories);
setLoading(false);
} else {
console.error('Failed to fetch categories');
setLoading(false);
}
});
} else {
setLoading(false);
}
};
fetchCategories();
}, [socket]);
const content = (
<>
<Typography variant="body1" paragraph>
Hier finden Sie eine Übersicht aller verfügbaren Seiten unserer Website.
</Typography>
{/* @note Static site links */}
<Typography variant="h6" sx={{ mt: 3, mb: 2, fontWeight: 'bold' }}>
Seiten
</Typography>
<List>
{sitemapLinks.map((link) => (
<ListItem
button
component={link.route === false ? 'a' : RouterLink}
{...(link.route === false ? { href: link.url } : { to: link.url })}
key={link.url}
sx={{
py: 1,
borderBottom: '1px solid',
borderColor: 'divider'
}}
>
<ListItemText primary={link.title} />
</ListItem>
))}
</List>
{/* @note Category links */}
<Typography variant="h6" sx={{ mt: 4, mb: 2, fontWeight: 'bold' }}>
Kategorien
</Typography>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<List>
{categories.map((category) => (
<ListItem
button
component={RouterLink}
to={`/Kategorie/${category.seoName}`}
key={category.id}
sx={{
py: 1,
pl: 2 + (category.level * 2), // Indent based on category level
borderBottom: '1px solid',
borderColor: 'divider'
}}
>
<ListItemText
primary={category.name}
sx={{
'& .MuiTypography-root': {
fontSize: category.level === 0 ? '1rem' : '0.9rem',
fontWeight: category.level === 0 ? 'bold' : 'normal',
color: category.level === 0 ? 'primary.main' : 'text.primary'
}
}}
/>
</ListItem>
))}
</List>
)}
</>
);
return <LegalPage title="Sitemap" content={content} />;
};
export default Sitemap;

719
src/pages/UsersPage.js Normal file
View File

@@ -0,0 +1,719 @@
import React from 'react';
import {
Container,
Typography,
Paper,
Box,
Divider,
Grid,
Card,
CardContent,
List,
ListItem,
ListItemText,
Chip,
Avatar,
Tabs,
Tab,
Stack,
Button,
Snackbar,
Alert,
Link as MuiLink
} from '@mui/material';
import { Navigate, Link } from 'react-router-dom';
import PersonIcon from '@mui/icons-material/Person';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import GroupIcon from '@mui/icons-material/Group';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import { ADMIN_COLORS, getAdminStyles } from '../theme/adminColors.js';
class UsersPage extends React.Component {
constructor(props) {
super(props);
this.state = {
user: null,
users: [],
totalCount: 0,
totalOrders: 0,
loading: true,
redirect: false,
switchingUser: false,
notification: {
open: false,
message: '',
severity: 'success'
},
currentlyImpersonating: null
};
}
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 });
}
}
componentDidMount() {
this.loadInitialData();
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, reload data
this.loadInitialData();
}
}
componentWillUnmount() {
// 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('getUsers', (response) => {
if (response.success) {
console.log('Users:', response.data.users);
console.log('Total count:', response.data.totalCount);
console.log('Total orders:', response.data.totalOrders);
this.setState({
users: response.data.users,
totalCount: response.data.totalCount,
totalOrders: response.data.totalOrders
});
} else {
console.error('Error:', response.error);
}
});
}
}
formatDate = (dateString) => {
try {
const date = new Date(dateString);
// Check if date is valid
if (isNaN(date.getTime())) {
return dateString; // Return original string if date is invalid
}
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (error) {
console.error('Error formatting date:', error);
return dateString; // Return original string if formatting fails
}
}
getOrderStatusColor = (status) => {
switch (status) {
case 'completed':
case 'delivered':
return 'success';
case 'pending':
return 'warning';
case 'processing':
case 'shipped':
return 'info';
case 'cancelled':
return 'error';
default:
return 'default';
}
}
getOrderStatusChipColor = (status) => {
switch (status) {
case 'completed':
case 'delivered':
return ADMIN_COLORS.primary;
case 'pending':
return ADMIN_COLORS.warning;
case 'processing':
case 'shipped':
return ADMIN_COLORS.secondary;
case 'cancelled':
return ADMIN_COLORS.error;
default:
return ADMIN_COLORS.secondaryText;
}
}
formatPrice = (price) => {
return typeof price === 'number'
? `${price.toFixed(2)}`
: price;
}
handleSwitchUser = (email) => {
if (!this.props.socket || !this.props.socket.connected) {
this.showNotification('Socket not connected', 'error');
return;
}
this.setState({ switchingUser: true });
this.props.socket.emit('switchUser', { email }, (response) => {
console.log('Switch user response:', response);
this.setState({ switchingUser: false });
if (response.success) {
this.setState({ currentlyImpersonating: response.data.targetUser });
this.showNotification(`Successfully switched to user: ${email}`, 'success');
// Update sessionStorage with the switched user info
const currentUser = JSON.parse(sessionStorage.getItem('user') || '{}');
const switchedUser = {
...currentUser,
id: response.data.targetUser.id,
email: response.data.targetUser.email,
admin: true, // Admin privileges are preserved
originalAdmin: response.data.originalAdmin
};
sessionStorage.setItem('user', JSON.stringify(switchedUser));
// Trigger userLoggedIn event to refresh other components
window.dispatchEvent(new Event('userLoggedIn'));
} else {
this.showNotification(`Failed to switch user: ${response.error}`, 'error');
}
});
}
handleSwitchBackToAdmin = () => {
if (!this.props.socket || !this.props.socket.connected) {
this.showNotification('Socket not connected', 'error');
return;
}
this.setState({ switchingUser: true });
this.props.socket.emit('switchBackToAdmin', (response) => {
console.log('Switch back to admin response:', response);
this.setState({ switchingUser: false });
if (response.success) {
this.setState({ currentlyImpersonating: null });
this.showNotification(`Switched back to admin`, 'success');
// Restore original admin info in sessionStorage
const currentUser = JSON.parse(sessionStorage.getItem('user') || '{}');
if (currentUser.originalAdmin) {
const restoredAdmin = {
...currentUser,
id: currentUser.originalAdmin.id,
email: currentUser.originalAdmin.email,
admin: true
};
delete restoredAdmin.originalAdmin;
sessionStorage.setItem('user', JSON.stringify(restoredAdmin));
}
// Trigger userLoggedIn event to refresh other components
window.dispatchEvent(new Event('userLoggedIn'));
} else {
this.showNotification(`Failed to switch back: ${response.error}`, 'error');
}
});
}
showNotification = (message, severity = 'success') => {
console.log('Showing notification:', message, severity);
this.setState({
notification: {
open: true,
message,
severity
}
});
}
handleCloseNotification = () => {
this.setState({
notification: {
...this.state.notification,
open: false
}
});
}
render() {
const { users, totalCount, totalOrders } = 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 = users && users.length > 0;
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={1}
sx={{
px: 2,
'& .MuiTabs-indicator': {
backgroundColor: ADMIN_COLORS.primary
}
}}
>
<Tab
label="Dashboard"
component={Link}
to="/admin"
sx={{
textTransform: 'none',
color: ADMIN_COLORS.primaryText,
fontFamily: ADMIN_COLORS.fontFamily,
'&:hover': {
color: ADMIN_COLORS.primaryBright
}
}}
/>
<Tab
label="Users"
component={Link}
to="/admin/users"
sx={{
textTransform: 'none',
fontWeight: 'bold',
color: ADMIN_COLORS.primary,
fontFamily: ADMIN_COLORS.fontFamily
}}
/>
<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>
<Paper
elevation={3}
sx={{
p: 3,
mb: 4,
...styles.contentPaper
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography
variant="h5"
sx={{
display: 'flex',
alignItems: 'center',
...styles.primaryHeading
}}
>
<GroupIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
User Management
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{this.state.currentlyImpersonating && (
<>
<Chip
label={`Impersonating: ${this.state.currentlyImpersonating.email}`}
size="small"
sx={{
fontWeight: 'medium',
backgroundColor: ADMIN_COLORS.magenta,
color: ADMIN_COLORS.hoverBackground,
fontFamily: ADMIN_COLORS.fontFamily
}}
/>
<Button
variant="outlined"
size="small"
onClick={this.handleSwitchBackToAdmin}
disabled={this.state.switchingUser}
sx={{
textTransform: 'none',
color: ADMIN_COLORS.primaryText,
borderColor: ADMIN_COLORS.border,
fontFamily: ADMIN_COLORS.fontFamily,
'&:hover': {
borderColor: ADMIN_COLORS.primary,
backgroundColor: 'rgba(80, 250, 123, 0.1)'
}
}}
>
Switch Back to Admin
</Button>
</>
)}
</Box>
</Box>
<Stack direction="row" spacing={4} sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<GroupIcon sx={{ mr: 1, color: ADMIN_COLORS.secondary }} />
<Typography
variant="subtitle1"
sx={{
...styles.primaryText
}}
>
Total Users: <strong style={{ color: ADMIN_COLORS.warning }}>{totalCount}</strong>
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<ShoppingCartIcon sx={{ mr: 1, color: ADMIN_COLORS.secondary }} />
<Typography
variant="subtitle1"
sx={{
...styles.primaryText
}}
>
Total Orders: <strong style={{ color: ADMIN_COLORS.warning }}>{totalOrders}</strong>
</Typography>
</Box>
</Stack>
{!hasUsers && (
<Typography
variant="body1"
sx={{
mt: 2,
...styles.secondaryText
}}
>
No users found.
</Typography>
)}
{hasUsers && (
<Grid container spacing={3} sx={{ mt: 1 }}>
{users.map((user, i) => (
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={user.id || i}>
<Card
variant="outlined"
sx={{
height: '100%',
...styles.card
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, width: '100%' }}>
<Avatar sx={{
mr: 2,
bgcolor: user.admin ? ADMIN_COLORS.magenta : ADMIN_COLORS.secondary,
color: ADMIN_COLORS.hoverBackground
}}>
{user.admin ? <AdminPanelSettingsIcon /> : <PersonIcon />}
</Avatar>
<Box sx={{ flexGrow: 1, minWidth: 0, overflow: 'hidden' }}>
<Typography
variant="h6"
component="div"
noWrap
sx={{
...styles.primaryHeading
}}
>
User #{user.id}
</Typography>
<MuiLink
component="button"
onClick={() => this.handleSwitchUser(user.email)}
disabled={this.state.switchingUser}
sx={{
color: ADMIN_COLORS.secondary,
textDecoration: 'underline',
textDecorationColor: 'transparent',
cursor: 'pointer',
fontSize: '0.875rem',
fontWeight: 'medium',
border: 'none',
background: 'none',
padding: 0,
textAlign: 'left',
display: 'block',
width: '100%',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
fontFamily: ADMIN_COLORS.fontFamily,
'&:hover': {
textDecorationColor: ADMIN_COLORS.secondary,
color: ADMIN_COLORS.primaryBright
},
'&:disabled': {
color: ADMIN_COLORS.secondaryText,
cursor: 'not-allowed'
}
}}
title="Click to switch to this user"
>
{user.email}
</MuiLink>
</Box>
{user.admin == true&& (
<Box sx={{ flexShrink: 0, ml: 1 }}>
<Chip
label="Admin"
size="small"
sx={{
backgroundColor: ADMIN_COLORS.magenta,
color: ADMIN_COLORS.hoverBackground,
fontFamily: ADMIN_COLORS.fontFamily,
fontWeight: 'bold'
}}
/>
</Box>
)}
</Box>
<Divider sx={{ mb: 2, borderColor: ADMIN_COLORS.border }} />
<List disablePadding>
<ListItem sx={{ py: 0.5, px: 0 }}>
<ListItemText
primary="Status"
secondary={user.admin ? "Administrator" : "User"}
primaryTypographyProps={{
fontSize: '0.875rem',
fontWeight: 'medium',
color: ADMIN_COLORS.primaryText,
fontFamily: ADMIN_COLORS.fontFamily
}}
secondaryTypographyProps={{
color: user.admin ? ADMIN_COLORS.magenta : ADMIN_COLORS.secondary,
fontWeight: 'medium',
fontFamily: ADMIN_COLORS.fontFamily
}}
/>
</ListItem>
<ListItem sx={{ py: 0.5, px: 0 }}>
<ListItemText
primary="Created"
secondary={this.formatDate(user.created_at)}
primaryTypographyProps={{
fontSize: '0.875rem',
fontWeight: 'medium',
color: ADMIN_COLORS.primaryText,
fontFamily: ADMIN_COLORS.fontFamily
}}
secondaryTypographyProps={{
color: ADMIN_COLORS.warning,
fontFamily: ADMIN_COLORS.fontFamily
}}
/>
</ListItem>
<ListItem sx={{ py: 0.5, px: 0 }}>
<ListItemText
primary="Orders"
secondary={`${user.orderCount || 0} total`}
primaryTypographyProps={{
fontSize: '0.875rem',
fontWeight: 'medium',
color: ADMIN_COLORS.primaryText,
fontFamily: ADMIN_COLORS.fontFamily
}}
secondaryTypographyProps={{
color: ADMIN_COLORS.warning,
fontFamily: ADMIN_COLORS.fontFamily
}}
/>
</ListItem>
</List>
{/* All Orders */}
{user.orders && user.orders.length > 0 && (
<>
<Divider sx={{ my: 2, borderColor: ADMIN_COLORS.border }} />
<Typography
variant="subtitle2"
sx={{
mb: 1,
...styles.primaryHeading
}}
>
Orders
</Typography>
<List disablePadding>
{user.orders
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) // Sort by newest first
.map((order, orderIndex) => (
<ListItem key={order.orderId || orderIndex} sx={{ py: 0.5, px: 0 }}>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography
variant="caption"
sx={{
fontWeight: 'medium',
color: ADMIN_COLORS.primaryText,
fontFamily: ADMIN_COLORS.fontFamily
}}
>
{order.orderId || 'N/A'}
</Typography>
<Chip
label={order.status || 'unknown'}
size="small"
sx={{
fontSize: '0.65rem',
height: 'auto',
py: 0.25,
backgroundColor: this.getOrderStatusChipColor(order.status),
color: ADMIN_COLORS.hoverBackground,
fontFamily: ADMIN_COLORS.fontFamily,
fontWeight: 'bold'
}}
/>
</Box>
}
secondary={
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.5 }}>
<Typography
variant="caption"
sx={{
color: ADMIN_COLORS.warning,
fontFamily: ADMIN_COLORS.fontFamily
}}
>
{this.formatDate(order.created_at)}
</Typography>
<Typography
variant="caption"
sx={{
color: ADMIN_COLORS.primaryBright,
fontWeight: 'medium',
fontFamily: ADMIN_COLORS.fontFamily
}}
>
{order.totalCost ? this.formatPrice(order.totalCost) : 'N/A'}
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
</>
)}
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
</Paper>
{/* Notification Snackbar */}
<Snackbar
open={this.state.notification.open}
autoHideDuration={6000}
onClose={this.handleCloseNotification}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert
onClose={this.handleCloseNotification}
severity={this.state.notification.severity}
sx={{
width: '100%',
backgroundColor: ADMIN_COLORS.surfaceBackground,
border: `1px solid ${ADMIN_COLORS.border}`,
color: ADMIN_COLORS.primaryText,
fontFamily: ADMIN_COLORS.fontFamily,
'& .MuiAlert-icon': {
color: this.state.notification.severity === 'success'
? ADMIN_COLORS.primary
: this.state.notification.severity === 'error'
? ADMIN_COLORS.error
: ADMIN_COLORS.warning
},
'& .MuiAlert-action': {
color: ADMIN_COLORS.primaryText
}
}}
>
{this.state.notification.message}
</Alert>
</Snackbar>
</Container>
</Box>
);
}
}
export default UsersPage;

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { Typography } from '@mui/material';
import LegalPage from './LegalPage.js';
const Widerrufsrecht = () => {
const content = (
<>
<Typography variant="body1" paragraph>
Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat.
</Typography>
<Typography variant="body1" paragraph>
Um Ihr Widerrufsrecht auszuüben, müssen Sie uns
</Typography>
<Typography variant="body1" sx={{ ml: 2 }} paragraph>
Growheads<br />
Trachenberger Straße 14<br />
01129 Dresden<br />
E-Mail: service@growheads.de
</Typography>
<Typography variant="body1" paragraph>
mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief, per Telefax oder E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Folgen des Widerrufs
</Typography>
<Typography variant="body1" paragraph>
Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet. Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist. Sie haben die Waren unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem Sie uns über den Widerruf dieses Vertrags unterrichten, an uns zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn Sie die Waren vor Ablauf der Frist von vierzehn Tagen absenden. Sie tragen die unmittelbaren Kosten der Rücksendung der Waren. Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Hinweis auf Nichtbestehen des Widerrufsrechts
</Typography>
<Typography variant="body1" paragraph>
Das Widerrufsrecht besteht nicht für Waren die auf Kundenwunsch gefertigt oder zugeschnitten (Folien und Schläuche) wurden, kann aber nach Absprache gewährt werden. Düngerbehälter, bei denen das Verschlusssiegel entfernt oder durch Öffnen zerstört worden ist, sind ebenfalls vom Widerrufsrecht ausgeschlossen.
</Typography>
</>
);
return <LegalPage title="Widerrufsrecht" content={content} />;
};
export default Widerrufsrecht;

View File

@@ -0,0 +1,109 @@
import React, { Component } from 'react';
import GoogleAuthContext from '../contexts/GoogleAuthContext.js';
class GoogleAuthProvider extends Component {
constructor(props) {
super(props);
this.state = {
isLoaded: false,
error: null,
loadingStarted: false
};
// Initialize Google API client ID
this.clientId = props.clientId;
}
componentDidMount() {
// Load the Google Sign-In API script
this.loadGoogleScript();
}
loadGoogleScript = () => {
// Prevent multiple load attempts
if (this.state.loadingStarted) {
return;
}
this.setState({ loadingStarted: true });
// Check if the script is already loaded
if (window.google && window.google.accounts && window.google.accounts.id) {
this.setState({ isLoaded: true });
return;
}
if (document.getElementById('google-auth-script')) {
// Script tag exists but may not be fully loaded yet
const checkGoogleLoaded = setInterval(() => {
if (window.google && window.google.accounts && window.google.accounts.id) {
this.setState({ isLoaded: true });
clearInterval(checkGoogleLoaded);
}
}, 100);
// Set a timeout to stop checking after a reasonable time
setTimeout(() => {
clearInterval(checkGoogleLoaded);
if (!this.state.isLoaded) {
this.setState({
error: new Error('Timeout waiting for Google Sign-In API to load'),
isLoaded: false
});
}
}, 10000); // 10 second timeout
return;
}
// Create and add the script tag
const script = document.createElement('script');
script.src = 'https://accounts.google.com/gsi/client';
script.id = 'google-auth-script';
script.async = true;
script.defer = true;
script.onload = () => {
// Give a small delay to ensure the API is fully initialized
setTimeout(() => {
if (window.google && window.google.accounts && window.google.accounts.id) {
this.setState({ isLoaded: true });
} else {
this.setState({
error: new Error('Google Sign-In API loaded but not initialized properly'),
isLoaded: false
});
}
}, 100);
};
script.onerror = () => {
this.setState({
error: new Error('Failed to load Google Sign-In API script'),
isLoaded: false
});
};
document.body.appendChild(script);
};
render() {
const { children } = this.props;
const { isLoaded, error } = this.state;
// Context value includes loading state, clientId and any errors
const contextValue = {
clientId: this.clientId,
isLoaded,
error
};
return (
<GoogleAuthContext.Provider value={contextValue}>
{children}
</GoogleAuthContext.Provider>
);
}
}
export default GoogleAuthProvider;

View File

@@ -0,0 +1,129 @@
import React, { Component } from "react";
import io from "socket.io-client";
import SocketContext from "../contexts/SocketContext.js";
import { isUserLoggedIn } from "../components/LoginComponent.js";
class SocketProvider extends Component {
constructor(props) {
super(props);
this.socket = null;
this.state = {
connected: false,
showPrerenderFallback: true,
};
}
connectToSocket(url) {
if (this.socket) {
this.socket.disconnect();
}
console.log(`SocketProvider: Connecting to socket server... ${url}`);
this.socket = io(url, {
transports: ["websocket"],
});
this.socket.on("connect", () => {
const user = isUserLoggedIn();
console.log("SocketProvider: connected", user);
// Check for ID parameter in URL and emit setId if present
const urlParams = new URLSearchParams(window.location.search);
const id = urlParams.get("id");
if (id) {
console.log("SocketProvider: Emitting setId with ID:", id);
this.socket.emit("setId", { id });
}
if (user && user.isLoggedIn && user.user.token)
this.socket.emit(
"verifyToken",
{ token: user.user.token },
(response) => {
console.log("SocketProvider: verifyToken", response);
if (response.success) {
try {
window.cart = JSON.parse(response.user.cart);
window.dispatchEvent(new CustomEvent("cart"));
} catch (error) {
console.error("Error parsing cart :", response.user, error);
}
} else {
sessionStorage.removeItem("user");
window.location.reload();
}
}
);
this.setState({ connected: true, showPrerenderFallback: false });
console.log("SocketProvider: Socket connected successfully");
});
this.socket.on("disconnect", () => {
this.setState({ connected: false });
console.log("SocketProvider: Socket disconnected");
});
this.socket.on("connect_error", (error) => {
console.error("SocketProvider: Connection error:", error);
this.handleConnectionFailure();
});
this.socket.on("reconnect_attempt", (attemptNumber) => {
console.log(`SocketProvider: Reconnection attempt ${attemptNumber}`);
});
this.socket.on("reconnect_failed", () => {
console.error("SocketProvider: Failed to reconnect");
this.handleConnectionFailure();
});
}
handleConnectionFailure() {
// Check if prerendered fallback content is available
if (typeof window !== "undefined" && window.__PRERENDER_FALLBACK__) {
console.log("SocketProvider: Using prerendered fallback content");
this.setState({ showPrerenderFallback: true });
}
}
componentDidMount() {
this.connectToSocket(this.props.url);
}
componentWillUnmount() {
if (this.socket) {
console.log("SocketProvider: Disconnecting socket");
this.socket.disconnect();
}
}
render() {
const showPrerenderFallback = this.state.showPrerenderFallback &&
typeof window !== "undefined" &&
window.__PRERENDER_FALLBACK__;
return (
<SocketContext.Provider value={this.socket}>
{/* Always render children but control visibility */}
<div style={{ display: this.state.connected ? 'block' : 'none' }}>
{this.props.children}
</div>
{/* Show prerendered fallback when appropriate */}
{showPrerenderFallback && (
<div
dangerouslySetInnerHTML={{
__html: window.__PRERENDER_FALLBACK__.content,
}}
/>
)}
{/* Show default fallback when not connected and no prerendered content */}
{!this.state.connected && !showPrerenderFallback && this.props.fallback}
</SocketContext.Provider>
);
}
}
export default SocketProvider;

View File

@@ -0,0 +1,119 @@
class TelemetryService {
constructor(socket) {
this.socket = socket;
this.originalConsole = {
log: console.log,
warn: console.warn,
error: console.error
};
this.isInitialized = false;
}
init() {
if (this.isInitialized || !this.socket) {
return;
}
// Override console.log
console.log = (...args) => {
// Call original console.log first
this.originalConsole.log.apply(console, args);
// Send to telemetry
this.sendTelemetry('info', this.formatMessage(args));
};
// Override console.warn
console.warn = (...args) => {
// Call original console.warn first
this.originalConsole.warn.apply(console, args);
// Send to telemetry
this.sendTelemetry('warn', this.formatMessage(args));
};
// Override console.error
console.error = (...args) => {
// Call original console.error first
this.originalConsole.error.apply(console, args);
// Send to telemetry with stack trace
const errorWithStack = args.map(arg => {
if (arg instanceof Error) {
return `${arg.message}\n${arg.stack}`;
}
return arg;
});
this.sendTelemetry('error', this.formatMessage(errorWithStack));
};
// Capture unhandled errors
window.addEventListener('error', (event) => {
this.sendTelemetry('error', `Unhandled Error: ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`);
});
// Capture unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
this.sendTelemetry('error', `Unhandled Promise Rejection: ${event.reason}`);
});
this.isInitialized = true;
}
formatMessage(args) {
return args.map(arg => {
if (typeof arg === 'object' && arg !== null) {
// Prevent stringifying large or complex objects that might cause issues
if (arg.constructor.name === 'Object' || Array.isArray(arg)) {
const keyCount = Object.keys(arg).length;
// Heuristic: if an object has many keys or is a component instance, don't stringify it.
if (keyCount > 20 || Object.prototype.hasOwnProperty.call(arg, '_reactinternals')) {
return `[${Array.isArray(arg) ? 'Array' : 'Object'}: ${keyCount} keys]`;
}
}
try {
return JSON.stringify(arg, null, 2);
} catch {
// If stringify fails (e.g., circular reference), return a placeholder.
return `[Unstringifiable ${typeof arg}]`;
}
}
return String(arg);
}).join(' ');
}
sendTelemetry(level, message) {
if (!this.socket) {
return;
}
try {
this.socket.emit('telemetry', {
type: 'consoleLog',
level: level,
message: message,
timecode: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent
});
} catch (error) {
// Use original console.error to avoid infinite loop
this.originalConsole.error('Failed to send telemetry:', error);
}
}
destroy() {
if (!this.isInitialized) {
return;
}
// Restore original console methods
console.log = this.originalConsole.log;
console.warn = this.originalConsole.warn;
console.error = this.originalConsole.error;
this.isInitialized = false;
}
}
export default TelemetryService;

40
src/theme.js Normal file
View File

@@ -0,0 +1,40 @@
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#2E7D32', // Forest green
light: '#4CAF50', // Regular green
dark: '#1B5E20', // Dark green
},
secondary: {
main: '#81C784', // Light green
light: '#A5D6A7', // Very light green
dark: '#66BB6A', // Mid green
},
background: {
default: '#C8E6C9', // Darker green background
paper: '#ffffff', // Darker gray
},
text: {
primary: '#33691E', // Dark green text
secondary: '#558B2F', // Mid green text
},
success: {
main: '#43A047', // Green success
},
error: {
main: '#D32F2F', // Keep red for errors/out of stock
},
},
typography: {
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif",
h4: {
fontWeight: 600,
color: '#33691E',
},
},
});
export default theme;

68
src/theme/adminColors.js Normal file
View File

@@ -0,0 +1,68 @@
// Dark Terminal Theme Colors for Admin Pages
export const ADMIN_COLORS = {
// Backgrounds
pageBackground: '#0f0f23', // Darkest - full page background
surfaceBackground: '#1e1e2e', // Medium - main content areas (papers, cards)
hoverBackground: '#282a36', // Lighter - hover states and interactive elements
// Text
primaryText: '#f8f8f2', // Main text color
secondaryText: '#6272a4', // Muted text, timestamps, etc.
// Accents
primary: '#50fa7b', // Main accent - headers, success, active states
primaryBright: '#69ff94', // Brighter version for hover effects
secondary: '#8be9fd', // Secondary accent - icons, links
warning: '#f1fa8c', // Warning/pending states
error: '#ff5555', // Error/cancelled states
magenta: '#ff79c6', // Special accent - admin badges, chips
// Borders
border: '#6272a4', // Standard border color
// Typography
fontFamily: 'monospace', // Consistent terminal-style font
};
// Helper function to get consistent styling objects
export const getAdminStyles = () => ({
pageContainer: {
backgroundColor: ADMIN_COLORS.pageBackground,
minHeight: '100vh'
},
tabBar: {
backgroundColor: ADMIN_COLORS.surfaceBackground,
border: `1px solid ${ADMIN_COLORS.border}`
},
contentPaper: {
backgroundColor: ADMIN_COLORS.surfaceBackground,
border: `1px solid ${ADMIN_COLORS.border}`
},
card: {
backgroundColor: ADMIN_COLORS.surfaceBackground,
border: `1px solid ${ADMIN_COLORS.border}`,
'&:hover': {
backgroundColor: ADMIN_COLORS.hoverBackground,
borderColor: ADMIN_COLORS.primary
}
},
primaryText: {
color: ADMIN_COLORS.primaryText,
fontFamily: ADMIN_COLORS.fontFamily
},
primaryHeading: {
color: ADMIN_COLORS.primary,
fontFamily: ADMIN_COLORS.fontFamily,
fontWeight: 'bold'
},
secondaryText: {
color: ADMIN_COLORS.secondaryText,
fontFamily: ADMIN_COLORS.fontFamily
}
});

View File

@@ -0,0 +1,26 @@
import React, { lazy, Suspense, memo } from 'react';
// @note Lazy load html-react-parser to reduce initial bundle size
const HtmlParserComponent = lazy(() =>
import('html-react-parser').then(module => ({
default: ({ html, options }) => module.default(html, options)
}))
);
/**
* LazyHtmlParser - A component that lazy loads html-react-parser
* This reduces the initial bundle size by ~20KB
*/
class LazyHtmlParser extends React.Component {
render() {
const { html, options, fallback = null } = this.props;
return (
<Suspense fallback={fallback}>
<HtmlParserComponent html={html} options={options} />
</Suspense>
);
}
}
export default memo(LazyHtmlParser);

View File

@@ -0,0 +1,232 @@
// Utility for generating animated border styles with configurable gradients
/**
* Generates animated border CSS styles with configurable gradients
* @param {Object} config - Configuration object
* @param {Array} config.gradientColors - Array of color strings for the gradient
* @param {string} config.animationDirection - 'spin' or 'spinReverse'
* @param {number} config.animationDuration - Duration in seconds (default: 5)
* @param {string} config.className - CSS class name for the card
* @param {Object} config.boxShadow - Box shadow configuration
* @param {number} config.borderPadding - Border padding in px (default: 8)
* @param {number} config.borderRadius - Border radius in px (default: 24)
* @returns {string} CSS string for the animated border
*/
export const generateAnimatedBorderStyle = ({
gradientColors,
animationDirection = "spin",
animationDuration = 5,
className,
boxShadow,
borderPadding = 8,
borderRadius = 24,
}) => {
const gradientString = gradientColors.join(", ");
return `
.${className} {
padding: ${borderPadding}px !important;
border-radius: ${borderRadius}px !important;
${boxShadow ? `box-shadow: ${boxShadow} !important;` : ""}
}
.${className}::before {
content: '' !important;
position: absolute !important;
top: -50% !important;
left: -50% !important;
width: 200% !important;
height: 200% !important;
background: conic-gradient(
from 0deg,
${gradientString}
) !important;
animation: ${animationDirection} ${animationDuration}s linear infinite !important;
}
`;
};
/**
* Base CSS for animated border functionality
*/
export const baseAnimatedBorderStyle = `
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spinReverse {
from {
transform: rotate(360deg);
}
to {
transform: rotate(0deg);
}
}
/* Seamless carousel animation */
@keyframes seamless-scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-33.333%);
}
}
/* Carousel container styles */
.carousel-container {
overflow: hidden !important;
position: relative !important;
width: 100% !important;
padding: 20px 0 !important;
}
.carousel-track {
display: flex !important;
animation: seamless-scroll 45s linear infinite !important;
gap: 20px !important;
width: fit-content !important;
}
.carousel-track:hover {
animation-play-state: paused !important;
}
.carousel-item {
flex: 0 0 130px !important;
height: 130px !important;
}
/* Base container with border space */
.animated-border-card {
position: relative !important;
display: block !important;
border-radius: 20px !important;
padding: 4px !important;
overflow: hidden !important;
background: #000 !important;
}
/* Inner content with white background */
.animated-border-card > a {
position: relative !important;
display: block !important;
background: white !important;
border-radius: 16px !important;
z-index: 1 !important;
}
`;
/**
* Predefined gradient configurations
*/
export const gradientPresets = {
seeds: {
gradientColors: [
"#1B5E20",
"#2E7D32",
"#388E3C",
"#43A047",
"#4CAF50",
"#66BB6A",
"#81C784",
"#A5D6A7",
"#C8E6C9",
"#E8F5E8",
"#C8E6C9",
"#A5D6A7",
"#81C784",
"#66BB6A",
"#4CAF50",
"#43A047",
"#388E3C",
"#2E7D32",
"#1B5E20",
],
animationDirection: "spin",
className: "seeds-card",
borderPadding: 8,
borderRadius: 24,
boxShadow:
"0 0 40px rgba(0, 200, 83, 0.5), 0 0 80px rgba(76, 175, 80, 0.3)",
},
cutlings: {
gradientColors: [
"#D4B896",
"#C8A882",
"#B8A082",
"#A8956E",
"#9E8B5A",
"#8D7F46",
"#7A7F32",
"#6B8E23",
"#7CB342",
"#8BC34A",
"#9CCC65",
"#AED581",
"#C5E1A5",
"#DCEDC8",
"#C5E1A5",
"#AED581",
"#9CCC65",
"#8BC34A",
"#7CB342",
"#6B8E23",
"#7A7F32",
"#8D7F46",
"#9E8B5A",
"#A8956E",
"#B8A082",
"#C8A882",
"#D4B896",
],
animationDirection: "spinReverse",
className: "cutlings-card",
borderPadding: 8,
borderRadius: 24,
boxShadow:
"0 0 20px rgba(212, 184, 150, 0.4), 0 0 40px rgba(168, 149, 110, 0.3)",
},
};
/**
* Generates complete animated border styles using presets or custom config
* @param {string|Object} config - Preset name or custom configuration object
* @returns {string} Complete CSS string
*/
export const getAnimatedBorderStyles = (config) => {
let styleConfig;
if (typeof config === "string" && gradientPresets[config]) {
styleConfig = gradientPresets[config];
} else if (typeof config === "object") {
styleConfig = config;
} else {
throw new Error("Invalid configuration provided");
}
return baseAnimatedBorderStyle + generateAnimatedBorderStyle(styleConfig);
};
/**
* Generates styles for multiple presets efficiently (avoids duplicate base styles)
* @param {Array<string>} presetNames - Array of preset names
* @returns {string} Complete CSS string with all presets
*/
export const getCombinedAnimatedBorderStyles = (presetNames) => {
const specificStyles = presetNames
.map((presetName) => {
if (!gradientPresets[presetName]) {
throw new Error(`Preset '${presetName}' not found`);
}
return generateAnimatedBorderStyle(gradientPresets[presetName]);
})
.join("");
return baseAnimatedBorderStyle + specificStyles;
};

51
src/utils/cartUtils.js Normal file
View File

@@ -0,0 +1,51 @@
// Cart-Sync-Utilities
/**
* Vereint lokalen und Server-Warenkorb intelligent.
*/
export const mergeCarts = (localCart = [], serverCart = []) => {
try {
const localCartMap = new Map();
localCart.forEach(item => {
if (item?.id) localCartMap.set(item.id, item);
});
const mergedCart = [];
const processedIds = new Set();
serverCart.forEach(serverItem => {
if (!serverItem?.id) return;
const localItem = localCartMap.get(serverItem.id);
if (localItem) {
mergedCart.push({
...serverItem,
quantity: Math.max(serverItem.quantity, localItem.quantity),
});
} else {
mergedCart.push({ ...serverItem });
}
processedIds.add(serverItem.id);
});
localCart.forEach(localItem => {
if (localItem?.id && !processedIds.has(localItem.id)) {
mergedCart.push({ ...localItem });
}
});
return mergedCart;
} catch (error) {
console.error('Error merging carts:', error);
return localCart || [];
}
};
/**
* Nutzt lokalen Warenkorb und archiviert den Server-Warenkorb.
*/
export const localAndArchiveServer = (localCart, serverCart) => {
try {
window.archivedServerCart = serverCart;
window.cart = localCart;
return localCart;
} catch (error) {
console.error('Error applying local cart and archiving server cart:', error);
return window.cart || [];
}
};

53
src/utils/nnsort.js Normal file
View File

@@ -0,0 +1,53 @@
export function sortByFuzzySimilarity(list, searchTerm) {
const searchWords = searchTerm.toLowerCase().split(/\W+/).filter(Boolean);
return list.slice().sort((textA, textB) => {
const scoreA = fuzzySimilarityScore(textA, searchWords);
const scoreB = fuzzySimilarityScore(textB, searchWords);
return scoreB - scoreA;
});
}
function fuzzySimilarityScore(text, searchWords) {
const textWords = text.toLowerCase().split(/\W+/).filter(Boolean);
let totalScore = 0;
for (let searchWord of searchWords) {
let bestSimilarity = 0;
for (let word of textWords) {
const similarity = stringSimilarity(searchWord, word);
if (similarity > bestSimilarity) bestSimilarity = similarity;
}
if (bestSimilarity > 0.5) totalScore += bestSimilarity;
}
return totalScore;
}
function stringSimilarity(a, b) {
const distance = levenshteinDistance(a, b);
const maxLen = Math.max(a.length, b.length);
return maxLen === 0 ? 1 : 1 - (distance / maxLen);
}
function levenshteinDistance(a, b) {
const matrix = [];
for (let i = 0; i <= a.length; i++) matrix[i] = [i];
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
if (a.charAt(i - 1) === b.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j] + 1
);
}
}
}
return matrix[a.length][b.length];
}

View File

@@ -0,0 +1,24 @@
if (!window.sessionSettings) {
window.sessionSettings = {};
}
export const getSessionSetting = (key) => {
return window.sessionSettings[key];
};
export const setSessionSetting = (key, value) => {
window.sessionSettings[key] = value;
};
export const removeSessionSetting = (key) => {
delete window.sessionSettings[key];
};
export const clearAllSessionSettings = () => {
window.sessionSettings = {};
};
export const getAllSettingsWithPrefix = (prefix) => {
const filteredSettings = {};
Object.keys(window.sessionSettings).forEach(key => {
if (key.startsWith(prefix)) {
filteredSettings[key] = window.sessionSettings[key];
}
});
return filteredSettings;
};