Genesis
This commit is contained in:
325
src/App.js
Normal file
325
src/App.js
Normal file
@@ -0,0 +1,325 @@
|
||||
import React, { useState, useEffect, useRef, useContext, lazy, Suspense } from "react";
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Fab from "@mui/material/Fab";
|
||||
import SmartToyIcon from "@mui/icons-material/SmartToy";
|
||||
import PaletteIcon from "@mui/icons-material/Palette";
|
||||
|
||||
import SocketProvider from "./providers/SocketProvider.js";
|
||||
import SocketContext from "./contexts/SocketContext.js";
|
||||
import config from "./config.js";
|
||||
import ScrollToTop from "./components/ScrollToTop.js";
|
||||
//import TelemetryService from './services/telemetryService.js';
|
||||
|
||||
import Header from "./components/Header.js";
|
||||
import Footer from "./components/Footer.js";
|
||||
import Home from "./pages/Home.js";
|
||||
|
||||
// Lazy load all route components to reduce initial bundle size
|
||||
const Content = lazy(() => import(/* webpackChunkName: "content" */ "./components/Content.js"));
|
||||
const ProductDetailWithSocket = lazy(() => import(/* webpackChunkName: "product-detail" */ "./components/ProductDetailWithSocket.js"));
|
||||
const ProfilePageWithSocket = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
|
||||
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
|
||||
|
||||
// Lazy load admin pages - only loaded when admin users access them
|
||||
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));
|
||||
const UsersPage = lazy(() => import(/* webpackChunkName: "admin-users" */ "./pages/UsersPage.js"));
|
||||
const ServerLogsPage = lazy(() => import(/* webpackChunkName: "admin-logs" */ "./pages/ServerLogsPage.js"));
|
||||
|
||||
// Lazy load legal pages - rarely accessed
|
||||
const Datenschutz = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Datenschutz.js"));
|
||||
const AGB = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/AGB.js"));
|
||||
const Sitemap = lazy(() => import(/* webpackChunkName: "sitemap" */ "./pages/Sitemap.js"));
|
||||
const Impressum = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Impressum.js"));
|
||||
const Batteriegesetzhinweise = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Batteriegesetzhinweise.js"));
|
||||
const Widerrufsrecht = lazy(() => import(/* webpackChunkName: "legal" */ "./pages/Widerrufsrecht.js"));
|
||||
|
||||
// Lazy load special features
|
||||
const GrowTentKonfigurator = lazy(() => import(/* webpackChunkName: "konfigurator" */ "./pages/GrowTentKonfigurator.js"));
|
||||
const ChatAssistant = lazy(() => import(/* webpackChunkName: "chat" */ "./components/ChatAssistant.js"));
|
||||
|
||||
// Import theme from separate file to reduce main bundle size
|
||||
import defaultTheme from "./theme.js";
|
||||
// Lazy load theme customizer for development only
|
||||
const ThemeCustomizerDialog = lazy(() => import(/* webpackChunkName: "theme-customizer" */ "./components/ThemeCustomizerDialog.js"));
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
|
||||
const deleteMessages = () => {
|
||||
console.log("Deleting messages");
|
||||
window.chatMessages = [];
|
||||
};
|
||||
|
||||
// Component to initialize telemetry service with socket
|
||||
const TelemetryInitializer = ({ socket }) => {
|
||||
const telemetryServiceRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket && !telemetryServiceRef.current) {
|
||||
//telemetryServiceRef.current = new TelemetryService(socket);
|
||||
//telemetryServiceRef.current.init();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (telemetryServiceRef.current) {
|
||||
telemetryServiceRef.current.destroy();
|
||||
telemetryServiceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
};
|
||||
|
||||
const AppContent = ({ currentTheme, onThemeChange }) => {
|
||||
// State to manage chat visibility
|
||||
const [isChatOpen, setChatOpen] = useState(false);
|
||||
const [authVersion, setAuthVersion] = useState(0);
|
||||
// @note Theme customizer state for development mode
|
||||
const [isThemeCustomizerOpen, setThemeCustomizerOpen] = useState(false);
|
||||
|
||||
// Get current location
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (location.hash && location.hash.startsWith("#ORD-")) {
|
||||
if (location.pathname !== "/profile") {
|
||||
navigate(`/profile${location.hash}`, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [location, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLogin = () => {
|
||||
setAuthVersion((v) => v + 1);
|
||||
};
|
||||
window.addEventListener("userLoggedIn", handleLogin);
|
||||
return () => {
|
||||
window.removeEventListener("userLoggedIn", handleLogin);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Extract categoryId from pathname if on category route
|
||||
const getCategoryId = () => {
|
||||
const match = location.pathname.match(/^\/Kategorie\/(.+)$/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const categoryId = getCategoryId();
|
||||
|
||||
// Handler to toggle chat visibility
|
||||
const handleChatToggle = () => {
|
||||
if (isChatOpen)
|
||||
window.messageDeletionTimeout = setTimeout(deleteMessages, 1000 * 60);
|
||||
if (!isChatOpen && window.messageDeletionTimeout)
|
||||
clearTimeout(window.messageDeletionTimeout);
|
||||
setChatOpen(!isChatOpen);
|
||||
};
|
||||
|
||||
// Handler to close the chat
|
||||
const handleChatClose = () => {
|
||||
window.messageDeletionTimeout = setTimeout(deleteMessages, 1000 * 60);
|
||||
setChatOpen(false);
|
||||
};
|
||||
|
||||
// @note Theme customizer handlers for development mode
|
||||
const handleThemeCustomizerToggle = () => {
|
||||
setThemeCustomizerOpen(!isThemeCustomizerOpen);
|
||||
};
|
||||
|
||||
// Check if we're in development mode
|
||||
const isDevelopment = process.env.NODE_ENV === "development";
|
||||
|
||||
const socket = useContext(SocketContext);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: "100vh",
|
||||
mb: 0,
|
||||
pb: 0,
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<TelemetryInitializer socket={socket} />
|
||||
<Header active categoryId={categoryId} key={authVersion} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Suspense fallback={
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Box>
|
||||
}>
|
||||
<Routes>
|
||||
{/* Home page with text only */}
|
||||
<Route path="/" element={<Home />} />
|
||||
|
||||
{/* Category page - Render Content in parallel */}
|
||||
<Route
|
||||
path="/Kategorie/:categoryId"
|
||||
element={<Content socket={socket} />}
|
||||
/>
|
||||
{/* Single product page */}
|
||||
<Route
|
||||
path="/Artikel/:seoName"
|
||||
element={<ProductDetailWithSocket />}
|
||||
/>
|
||||
|
||||
{/* Search page - Render Content in parallel */}
|
||||
<Route path="/search" element={<Content socket={socket} />} />
|
||||
|
||||
{/* Profile page */}
|
||||
<Route path="/profile" element={<ProfilePageWithSocket />} />
|
||||
|
||||
{/* Reset password page */}
|
||||
<Route
|
||||
path="/resetPassword"
|
||||
element={<ResetPassword socket={socket} />}
|
||||
/>
|
||||
|
||||
{/* Admin page */}
|
||||
<Route path="/admin" element={<AdminPage socket={socket} />} />
|
||||
|
||||
{/* Admin Users page */}
|
||||
<Route path="/admin/users" element={<UsersPage socket={socket} />} />
|
||||
|
||||
{/* Admin Server Logs page */}
|
||||
<Route path="/admin/logs" element={<ServerLogsPage socket={socket} />} />
|
||||
|
||||
{/* Legal pages */}
|
||||
<Route path="/datenschutz" element={<Datenschutz />} />
|
||||
<Route path="/agb" element={<AGB />} />
|
||||
<Route path="/sitemap" element={<Sitemap />} />
|
||||
<Route path="/impressum" element={<Impressum />} />
|
||||
<Route
|
||||
path="/batteriegesetzhinweise"
|
||||
element={<Batteriegesetzhinweise />}
|
||||
/>
|
||||
<Route path="/widerrufsrecht" element={<Widerrufsrecht />} />
|
||||
|
||||
{/* Grow Tent Configurator */}
|
||||
<Route path="/Konfigurator" element={<GrowTentKonfigurator />} />
|
||||
|
||||
{/* Fallback for undefined routes */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Box>
|
||||
{/* Conditionally render the Chat Assistant */}
|
||||
{isChatOpen && (
|
||||
<Suspense fallback={<CircularProgress size={20} />}>
|
||||
<ChatAssistant
|
||||
open={isChatOpen}
|
||||
onClose={handleChatClose}
|
||||
socket={socket}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Chat AI Assistant FAB */}
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label="chat"
|
||||
size="small"
|
||||
sx={{
|
||||
position: "fixed",
|
||||
bottom: 31,
|
||||
right: 15,
|
||||
}}
|
||||
onClick={handleChatToggle} // Attach toggle handler
|
||||
>
|
||||
<SmartToyIcon sx={{ fontSize: "1.2rem" }} />
|
||||
</Fab>
|
||||
|
||||
{/* Development-only Theme Customizer FAB */}
|
||||
{isDevelopment && (
|
||||
<Fab
|
||||
color="secondary"
|
||||
aria-label="theme customizer"
|
||||
size="small"
|
||||
sx={{
|
||||
position: "fixed",
|
||||
bottom: 31,
|
||||
right: 75,
|
||||
}}
|
||||
onClick={handleThemeCustomizerToggle}
|
||||
>
|
||||
<PaletteIcon sx={{ fontSize: "1.2rem" }} />
|
||||
</Fab>
|
||||
)}
|
||||
|
||||
{/* Development-only Theme Customizer Dialog */}
|
||||
{isDevelopment && isThemeCustomizerOpen && (
|
||||
<Suspense fallback={<CircularProgress size={20} />}>
|
||||
<ThemeCustomizerDialog
|
||||
open={isThemeCustomizerOpen}
|
||||
onClose={() => setThemeCustomizerOpen(false)}
|
||||
theme={currentTheme}
|
||||
onThemeChange={onThemeChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Convert App to a functional component to use hooks
|
||||
const App = () => {
|
||||
// @note Theme state moved to App level to provide dynamic theming
|
||||
const [currentTheme, setCurrentTheme] = useState(defaultTheme);
|
||||
const [dynamicTheme, setDynamicTheme] = useState(createTheme(defaultTheme));
|
||||
|
||||
const handleThemeChange = (newTheme) => {
|
||||
setCurrentTheme(newTheme);
|
||||
setDynamicTheme(createTheme(newTheme));
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={dynamicTheme}>
|
||||
<CssBaseline />
|
||||
<SocketProvider
|
||||
url={config.apiBaseUrl}
|
||||
fallback={
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<AppContent
|
||||
currentTheme={currentTheme}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
</SocketProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
export { AppContent };
|
||||
59
src/PrerenderAppContent.js
Normal file
59
src/PrerenderAppContent.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Box, AppBar, Toolbar, Container} from '@mui/material';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo, CategoryList } from './components/header/index.js';
|
||||
import Home from './pages/Home.js';
|
||||
|
||||
const PrerenderAppContent = (socket) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
mb: 0,
|
||||
pb: 0,
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
|
||||
|
||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
|
||||
<Toolbar sx={{ minHeight: 64 }}>
|
||||
<Container maxWidth="lg" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{/* First row: Logo and ButtonGroup on xs, all items on larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexDirection: { xs: 'column', sm: 'row' }
|
||||
}}>
|
||||
{/* Top row for xs, single row for larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: { xs: 'space-between', sm: 'flex-start' }
|
||||
}}>
|
||||
<Logo />
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Toolbar>
|
||||
<CategoryList categoryId={209} activeCategoryId={null} socket={socket}/>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
|
||||
<Footer/>
|
||||
|
||||
|
||||
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default PrerenderAppContent;
|
||||
161
src/PrerenderCategory.js
Normal file
161
src/PrerenderCategory.js
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import { Box, AppBar, Toolbar, Container, Typography, Grid, Card, CardMedia, CardContent } from '@mui/material';
|
||||
import Footer from './components/Footer.js';
|
||||
import { Logo, SearchBar, CategoryList } from './components/header/index.js';
|
||||
|
||||
const PrerenderCategory = ({ categoryId, categoryName, categorySeoName, productData }) => {
|
||||
const products = productData?.products || [];
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
mb: 0,
|
||||
pb: 0,
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
|
||||
<Toolbar sx={{ minHeight: 64 }}>
|
||||
<Container maxWidth="lg" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{/* First row: Logo and ButtonGroup on xs, all items on larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexDirection: { xs: 'column', sm: 'row' }
|
||||
}}>
|
||||
{/* Top row for xs, single row for larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: { xs: 'space-between', sm: 'flex-start' }
|
||||
}}>
|
||||
<Logo />
|
||||
{/* SearchBar visible on sm and up */}
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' }, flexGrow: 1 }}>
|
||||
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Second row: SearchBar only on xs */}
|
||||
<Box sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
width: '100%',
|
||||
mt: 1, mb: 1
|
||||
}}>
|
||||
<SearchBar />
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Toolbar>
|
||||
<CategoryList categoryId={209} activeCategoryId={categoryId} />
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="xl" sx={{ py: 2, flexGrow: 1, display: 'grid', gridTemplateRows: '1fr' }}>
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 2fr', md: '1fr 3fr', lg: '1fr 4fr', xl: '1fr 4fr' },
|
||||
gap: 3
|
||||
}}>
|
||||
<Box>
|
||||
{/* Category Info */}
|
||||
<Typography variant="h4" component="h1" sx={{ mb: 2, color: 'primary.main' }}>
|
||||
{categoryName || `Category ${categoryId}`}
|
||||
</Typography>
|
||||
|
||||
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{/* Product list */}
|
||||
<Box sx={{
|
||||
bgcolor: 'background.paper',
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
minHeight: 400
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Products {products.length > 0 && `(${products.length})`}
|
||||
</Typography>
|
||||
|
||||
{products.length > 0 ? (
|
||||
<Grid container spacing={2}>
|
||||
{products.map((product) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={product.id}>
|
||||
<a
|
||||
href={`/Artikel/${product.seoName}`}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
display: 'block',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<Card sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.1)'
|
||||
}
|
||||
}}>
|
||||
<noscript>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={product.pictureList && product.pictureList.trim()
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
|
||||
: '/assets/images/nopicture.jpg'
|
||||
}
|
||||
alt={product.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
</noscript>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" component="h3" sx={{
|
||||
mb: 1,
|
||||
fontSize: '0.9rem',
|
||||
lineHeight: 1.2,
|
||||
height: '2.4em',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}>
|
||||
{product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Art.-Nr.: {product.articleNumber}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary.main" sx={{ fontWeight: 'bold' }}>
|
||||
{product.price ? `€${parseFloat(product.price).toFixed(2)}` : 'Preis auf Anfrage'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No products found in this category
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<Footer />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrerenderCategory;
|
||||
72
src/PrerenderHome.js
Normal file
72
src/PrerenderHome.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const React = require('react');
|
||||
const {
|
||||
Box,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Container
|
||||
} = require('@mui/material');
|
||||
const Footer = require('./components/Footer.js').default;
|
||||
const { Logo, CategoryList } = require('./components/header/index.js');
|
||||
const Home = require('./pages/Home.js').default;
|
||||
|
||||
class PrerenderHome extends React.Component {
|
||||
render() {
|
||||
return React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
mb: 0,
|
||||
pb: 0,
|
||||
bgcolor: 'background.default'
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
AppBar,
|
||||
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
||||
React.createElement(
|
||||
Toolbar,
|
||||
{ sx: { minHeight: 64 } },
|
||||
React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexDirection: { xs: 'column', sm: 'row' }
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: { xs: 'space-between', sm: 'flex-start' }
|
||||
}
|
||||
},
|
||||
React.createElement(Logo)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(CategoryList, { categoryId: 209, activeCategoryId: null })
|
||||
),
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { flexGrow: 1 } },
|
||||
React.createElement(Home)
|
||||
),
|
||||
React.createElement(Footer)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { default: PrerenderHome };
|
||||
111
src/PrerenderKonfigurator.js
Normal file
111
src/PrerenderKonfigurator.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
AppBar,
|
||||
Toolbar
|
||||
} from '@mui/material';
|
||||
import { Logo } from './components/header/index.js';
|
||||
|
||||
class PrerenderKonfigurator extends Component {
|
||||
render() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
mb: 0,
|
||||
pb: 0,
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
<AppBar
|
||||
position="sticky"
|
||||
color="primary"
|
||||
elevation={0}
|
||||
sx={{ zIndex: 1100 }}
|
||||
>
|
||||
<Toolbar sx={{ minHeight: 64 }}>
|
||||
<Container maxWidth="lg" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Logo />
|
||||
</Container>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="lg" sx={{ py: 4, flexGrow: 1 }}>
|
||||
<Paper elevation={2} sx={{ p: 4, borderRadius: 2 }}>
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Typography variant="h3" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
🌱 Growbox Konfigurator
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Stelle dein perfektes Indoor Grow Setup zusammen
|
||||
</Typography>
|
||||
|
||||
{/* Bundle Discount Information */}
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
mt: 3,
|
||||
p: 2,
|
||||
bgcolor: '#f8f9fa',
|
||||
border: '1px solid #e9ecef',
|
||||
maxWidth: 600,
|
||||
mx: 'auto'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold', mb: 2 }}>
|
||||
🎯 Bundle-Rabatt sichern!
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-around', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ color: '#1976d2', fontWeight: 'bold' }}>
|
||||
15%
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
ab 3 Produkten
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ color: '#ed6c02', fontWeight: 'bold' }}>
|
||||
24%
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
ab 5 Produkten
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ color: '#d32f2f', fontWeight: 'bold' }}>
|
||||
36%
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
ab 7 Produkten
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#666', mt: 1, display: 'block' }}>
|
||||
Je mehr Produkte du auswählst, desto mehr sparst du!
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Section 1 Header - Only show the title and subtitle */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
1. Growbox-Form auswählen
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Wähle zuerst die Grundfläche deiner Growbox aus
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PrerenderKonfigurator;
|
||||
196
src/PrerenderProduct.js
Normal file
196
src/PrerenderProduct.js
Normal file
@@ -0,0 +1,196 @@
|
||||
const React = require('react');
|
||||
const {
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardMedia,
|
||||
Grid,
|
||||
Box,
|
||||
Chip,
|
||||
Stack,
|
||||
AppBar,
|
||||
Toolbar
|
||||
} = require('@mui/material');
|
||||
const Footer = require('./components/Footer.js').default;
|
||||
const { Logo } = require('./components/header/index.js');
|
||||
|
||||
class PrerenderProduct extends React.Component {
|
||||
render() {
|
||||
const { productData } = this.props;
|
||||
|
||||
if (!productData) {
|
||||
return React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { py: 4 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h4', component: 'h1', gutterBottom: true },
|
||||
'Product not found'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const product = productData.product;
|
||||
const attributes = productData.attributes || [];
|
||||
const mainImage = product.pictureList && product.pictureList.trim()
|
||||
? `/assets/images/prod${product.pictureList.split(',')[0].trim()}.jpg`
|
||||
: '/assets/images/nopicture.jpg';
|
||||
|
||||
return React.createElement(
|
||||
Box,
|
||||
{
|
||||
sx: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
mb: 0,
|
||||
pb: 0,
|
||||
bgcolor: 'background.default'
|
||||
}
|
||||
},
|
||||
React.createElement(
|
||||
AppBar,
|
||||
{ position: 'sticky', color: 'primary', elevation: 0, sx: { zIndex: 1100 } },
|
||||
React.createElement(
|
||||
Toolbar,
|
||||
{ sx: { minHeight: 64 } },
|
||||
React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { display: 'flex', alignItems: 'center' } },
|
||||
React.createElement(Logo)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
Container,
|
||||
{ maxWidth: 'lg', sx: { py: 4, flexGrow: 1 } },
|
||||
React.createElement(
|
||||
Grid,
|
||||
{ container: true, spacing: 4 },
|
||||
// Product Image
|
||||
React.createElement(
|
||||
Grid,
|
||||
{ item: true, xs: 12, md: 6 },
|
||||
React.createElement(
|
||||
Card,
|
||||
{ sx: { height: '100%' } },
|
||||
React.createElement(
|
||||
CardMedia,
|
||||
{
|
||||
component: 'img',
|
||||
height: '400',
|
||||
image: mainImage,
|
||||
alt: product.name,
|
||||
sx: { objectFit: 'contain', p: 2 }
|
||||
}
|
||||
)
|
||||
)
|
||||
),
|
||||
// Product Details
|
||||
React.createElement(
|
||||
Grid,
|
||||
{ item: true, xs: 12, md: 6 },
|
||||
React.createElement(
|
||||
Stack,
|
||||
{ spacing: 3 },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h3', component: 'h1', gutterBottom: true },
|
||||
product.name
|
||||
),
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h6', color: 'text.secondary' },
|
||||
'Artikelnummer: '+product.articleNumber+' '+(product.gtin ? ` | GTIN: ${product.gtin}` : "")
|
||||
),
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { mt: 1 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h4', color: 'primary', fontWeight: 'bold' },
|
||||
new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(product.price)
|
||||
),
|
||||
product.vat && React.createElement(
|
||||
Typography,
|
||||
{ variant: 'body2', color: 'text.secondary' },
|
||||
`inkl. ${product.vat}% MwSt.`
|
||||
),
|
||||
React.createElement(
|
||||
Typography,
|
||||
{
|
||||
variant: 'body1',
|
||||
color: product.available ? 'success.main' : 'error.main',
|
||||
fontWeight: 'medium',
|
||||
sx: { mt: 1 }
|
||||
},
|
||||
product.available ? '✅ Verfügbar' : '❌ Nicht verfügbar'
|
||||
)
|
||||
),
|
||||
product.description && React.createElement(
|
||||
Box,
|
||||
{ sx: { mt: 2 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h6', gutterBottom: true },
|
||||
'Beschreibung'
|
||||
),
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
dangerouslySetInnerHTML: { __html: product.description },
|
||||
style: {
|
||||
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.5',
|
||||
color: '#33691E'
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
// Product specifications
|
||||
React.createElement(
|
||||
Box,
|
||||
{ sx: { mt: 2 } },
|
||||
React.createElement(
|
||||
Typography,
|
||||
{ variant: 'h6', gutterBottom: true },
|
||||
'Produktdetails'
|
||||
),
|
||||
React.createElement(
|
||||
Stack,
|
||||
{ direction: 'row', spacing: 1, flexWrap: 'wrap', gap: 1 },
|
||||
product.manufacturer && React.createElement(
|
||||
Chip,
|
||||
{ label: `Hersteller: ${product.manufacturer}`, variant: 'outlined' }
|
||||
),
|
||||
product.weight && product.weight > 0 && React.createElement(
|
||||
Chip,
|
||||
{ label: `Gewicht: ${product.weight} kg`, variant: 'outlined' }
|
||||
),
|
||||
...attributes.map((attr, index) =>
|
||||
React.createElement(
|
||||
Chip,
|
||||
{
|
||||
key: index,
|
||||
label: `${attr.cName}: ${attr.cWert}`,
|
||||
variant: 'outlined',
|
||||
color: 'primary'
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement(Footer)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { default: PrerenderProduct };
|
||||
41
src/PrerenderProfile.js
Normal file
41
src/PrerenderProfile.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Box,
|
||||
AppBar,
|
||||
Toolbar
|
||||
} from '@mui/material';
|
||||
import { Logo, CategoryList } from './components/header/index.js';
|
||||
|
||||
class PrerenderProfile extends Component {
|
||||
render() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
mb: 0,
|
||||
pb: 0,
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
<AppBar
|
||||
position="sticky"
|
||||
color="primary"
|
||||
elevation={0}
|
||||
sx={{ zIndex: 1100 }}
|
||||
>
|
||||
<Toolbar sx={{ minHeight: 64 }}>
|
||||
<Container maxWidth="lg" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Logo />
|
||||
</Container>
|
||||
</Toolbar>
|
||||
<CategoryList categoryId={209} activeCategoryId={null} />
|
||||
</AppBar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PrerenderProfile;
|
||||
439
src/components/AddToCartButton.js
Normal file
439
src/components/AddToCartButton.js
Normal file
@@ -0,0 +1,439 @@
|
||||
import React, { Component } from "react";
|
||||
import Button from "@mui/material/Button";
|
||||
import ButtonGroup from "@mui/material/ButtonGroup";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import RemoveIcon from "@mui/icons-material/Remove";
|
||||
import ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
|
||||
if (!Array.isArray(window.cart)) window.cart = [];
|
||||
|
||||
class AddToCartButton extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
if (!Array.isArray(window.cart)) window.cart = [];
|
||||
this.state = {
|
||||
quantity: window.cart.find((i) => i.id === this.props.id)
|
||||
? window.cart.find((i) => i.id === this.props.id).quantity
|
||||
: 0,
|
||||
isEditing: false,
|
||||
editValue: "",
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.cart = () => {
|
||||
if (!Array.isArray(window.cart)) window.cart = [];
|
||||
const item = window.cart.find((i) => i.id === this.props.id);
|
||||
const newQuantity = item ? item.quantity : 0;
|
||||
if (this.state.quantity !== newQuantity)
|
||||
this.setState({ quantity: newQuantity });
|
||||
};
|
||||
window.addEventListener("cart", this.cart);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("cart", this.cart);
|
||||
}
|
||||
|
||||
handleIncrement = () => {
|
||||
if (!window.cart) window.cart = [];
|
||||
const idx = window.cart.findIndex((item) => item.id === this.props.id);
|
||||
if (idx === -1) {
|
||||
window.cart.push({
|
||||
id: this.props.id,
|
||||
name: this.props.name,
|
||||
seoName: this.props.seoName,
|
||||
pictureList: this.props.pictureList,
|
||||
price: this.props.price,
|
||||
quantity: 1,
|
||||
weight: this.props.weight,
|
||||
vat: this.props.vat,
|
||||
versandklasse: this.props.versandklasse,
|
||||
availableSupplier: this.props.availableSupplier,
|
||||
available: this.props.available
|
||||
});
|
||||
} else {
|
||||
window.cart[idx].quantity++;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
};
|
||||
|
||||
handleDecrement = () => {
|
||||
if (!window.cart) window.cart = [];
|
||||
const idx = window.cart.findIndex((item) => item.id === this.props.id);
|
||||
if (idx !== -1) {
|
||||
if (window.cart[idx].quantity > 1) {
|
||||
window.cart[idx].quantity--;
|
||||
} else {
|
||||
window.cart.splice(idx, 1);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
}
|
||||
};
|
||||
|
||||
handleClearCart = () => {
|
||||
if (!window.cart) window.cart = [];
|
||||
const idx = window.cart.findIndex((item) => item.id === this.props.id);
|
||||
if (idx !== -1) {
|
||||
window.cart.splice(idx, 1);
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
}
|
||||
};
|
||||
|
||||
handleEditStart = () => {
|
||||
this.setState({
|
||||
isEditing: true,
|
||||
editValue: this.state.quantity > 0 ? this.state.quantity.toString() : "",
|
||||
});
|
||||
};
|
||||
|
||||
handleEditChange = (event) => {
|
||||
// Only allow numbers
|
||||
const value = event.target.value.replace(/[^0-9]/g, "");
|
||||
this.setState({ editValue: value });
|
||||
};
|
||||
|
||||
handleEditComplete = () => {
|
||||
let newQuantity = parseInt(this.state.editValue, 10);
|
||||
if (isNaN(newQuantity) || newQuantity < 0) {
|
||||
newQuantity = 0;
|
||||
}
|
||||
if (!window.cart) window.cart = [];
|
||||
const idx = window.cart.findIndex((item) => item.id === this.props.id);
|
||||
if (idx !== -1) {
|
||||
window.cart[idx].quantity = newQuantity;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("cart", {
|
||||
detail: { id: this.props.id, quantity: newQuantity },
|
||||
})
|
||||
);
|
||||
}
|
||||
this.setState({ isEditing: false });
|
||||
};
|
||||
|
||||
handleKeyPress = (event) => {
|
||||
if (event.key === "Enter") {
|
||||
this.handleEditComplete();
|
||||
}
|
||||
};
|
||||
|
||||
toggleCart = () => {
|
||||
// Dispatch an event that Header.js can listen for to toggle the cart
|
||||
window.dispatchEvent(new CustomEvent("toggle-cart"));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { quantity, isEditing, editValue } = this.state;
|
||||
const { available, size, incoming, availableSupplier } = this.props;
|
||||
|
||||
// Button is disabled if product is not available
|
||||
if (!available) {
|
||||
if (incoming) {
|
||||
return (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size={size || "medium"}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
backgroundColor: "#ffeb3b",
|
||||
color: "#000000",
|
||||
"&:hover": {
|
||||
backgroundColor: "#fdd835",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Ab{" "}
|
||||
{new Date(incoming).toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// If availableSupplier is 1, handle both quantity cases
|
||||
if (availableSupplier === 1) {
|
||||
// If no items in cart, show simple "Add to Cart" button with yellowish green
|
||||
if (quantity === 0) {
|
||||
return (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size={size || "medium"}
|
||||
onClick={this.handleIncrement}
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
backgroundColor: "#9ccc65", // yellowish green
|
||||
color: "#000000",
|
||||
"&:hover": {
|
||||
backgroundColor: "#8bc34a",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{this.props.steckling ? "Als Steckling vorbestellen" : "In den Korb"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// If items are in cart, show quantity controls with yellowish green
|
||||
if (quantity > 0) {
|
||||
return (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size={size || "medium"}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
"& .MuiButtonGroup-grouped:not(:last-of-type)": {
|
||||
borderRight: "1px solid rgba(255,255,255,0.3)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleDecrement}
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
px: 2,
|
||||
flexGrow: 2,
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={this.handleEditStart}
|
||||
>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
autoFocus
|
||||
value={editValue}
|
||||
onChange={this.handleEditChange}
|
||||
onBlur={this.handleEditComplete}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onFocus={(e) => e.target.select()}
|
||||
size="small"
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
style: {
|
||||
textAlign: "center",
|
||||
width: "30px",
|
||||
fontSize: "14px",
|
||||
padding: "2px",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
"aria-label": "quantity",
|
||||
}}
|
||||
sx={{ my: -0.5 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="button" sx={{ fontWeight: "bold" }}>
|
||||
{quantity}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleIncrement}
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
|
||||
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleClearCart}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "error.light" },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{this.props.cartButton && (
|
||||
<Tooltip title="Warenkorb öffnen" arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "primary.light" },
|
||||
}}
|
||||
>
|
||||
<ShoppingCartIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size={size || "medium"}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Out of Stock
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// If no items in cart, show simple "Add to Cart" button
|
||||
if (quantity === 0) {
|
||||
return (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size={size || "medium"}
|
||||
onClick={this.handleIncrement}
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
"&:hover": {
|
||||
backgroundColor: "primary.dark",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{this.props.steckling ? "Als Steckling vorbestellen" : "In den Korb"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// If items are in cart, show quantity controls
|
||||
return (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size={size || "medium"}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
"& .MuiButtonGroup-grouped:not(:last-of-type)": {
|
||||
borderRight: "1px solid rgba(255,255,255,0.3)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleDecrement}
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
px: 2,
|
||||
flexGrow: 2,
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={this.handleEditStart}
|
||||
>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
autoFocus
|
||||
value={editValue}
|
||||
onChange={this.handleEditChange}
|
||||
onBlur={this.handleEditComplete}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onFocus={(e) => e.target.select()}
|
||||
size="small"
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
style: {
|
||||
textAlign: "center",
|
||||
width: "30px",
|
||||
fontSize: "14px",
|
||||
padding: "2px",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
"aria-label": "quantity",
|
||||
}}
|
||||
sx={{ my: -0.5 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="button" sx={{ fontWeight: "bold" }}>
|
||||
{quantity}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleIncrement}
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
|
||||
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleClearCart}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "error.light" },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{this.props.cartButton && (
|
||||
<Tooltip title="Warenkorb öffnen" arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "primary.light" },
|
||||
}}
|
||||
>
|
||||
<ShoppingCartIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AddToCartButton;
|
||||
226
src/components/CartDropdown.js
Normal file
226
src/components/CartDropdown.js
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import List from '@mui/material/List';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import CartItem from './CartItem.js';
|
||||
|
||||
|
||||
class CartDropdown extends Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
cartItems = [],
|
||||
onClose,
|
||||
onCheckout,
|
||||
showDetailedSummary = false,
|
||||
deliveryMethod = '',
|
||||
deliveryCost = 0
|
||||
} = this.props;
|
||||
|
||||
// Calculate the total weight of all items in the cart
|
||||
const totalWeight = cartItems.reduce((sum, item) => {
|
||||
const weightPerItem = item.weight || 0;
|
||||
const quantity = item.quantity || 1;
|
||||
return sum + weightPerItem * quantity;
|
||||
}, 0);
|
||||
|
||||
// Calculate price breakdowns
|
||||
const priceCalculations = cartItems.reduce((acc, item) => {
|
||||
const totalItemPrice = item.price * item.quantity;
|
||||
const netPrice = totalItemPrice / (1 + item.vat / 100);
|
||||
const vatAmount = totalItemPrice - netPrice;
|
||||
|
||||
acc.totalGross += totalItemPrice;
|
||||
acc.totalNet += netPrice;
|
||||
|
||||
if (item.vat === 7) {
|
||||
acc.vat7 += vatAmount;
|
||||
} else if (item.vat === 19) {
|
||||
acc.vat19 += vatAmount;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
|
||||
|
||||
// Calculate detailed summary with shipping (similar to OrderSummary)
|
||||
const currencyFormatter = new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
});
|
||||
|
||||
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
|
||||
const shippingVat = deliveryCost - shippingNetPrice;
|
||||
const totalVat7 = priceCalculations.vat7;
|
||||
const totalVat19 = priceCalculations.vat19 + shippingVat;
|
||||
const totalGross = priceCalculations.totalGross + deliveryCost;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ bgcolor: 'primary.main', color: 'white', p: 2 }}>
|
||||
<Typography variant="h6">
|
||||
{cartItems.length} {cartItems.length === 1 ? 'Produkt' : 'Produkte'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{ cartItems && (
|
||||
<>
|
||||
<List sx={{ width: '100%' }}>
|
||||
{cartItems.map((item) => (
|
||||
<CartItem
|
||||
key={item.id}
|
||||
socket={this.props.socket}
|
||||
item={item}
|
||||
id={item.id}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{/* Display total weight if greater than 0 */}
|
||||
{totalWeight > 0 && (
|
||||
<Typography variant="subtitle2" sx={{ px: 2, mb: 1 }}>
|
||||
Gesamtgewicht: {totalWeight.toFixed(2)} kg
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Price breakdown table */}
|
||||
{cartItems.length > 0 && (
|
||||
<Box sx={{ px: 2, mb: 2 }}>
|
||||
{showDetailedSummary ? (
|
||||
// Detailed summary with shipping costs
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
Bestellübersicht
|
||||
</Typography>
|
||||
{deliveryMethod && (
|
||||
<Typography variant="body2" sx={{ mb: 1, color: 'text.secondary' }}>
|
||||
Versandart: {deliveryMethod}
|
||||
</Typography>
|
||||
)}
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Waren (netto):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(priceCalculations.totalNet)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>Versandkosten (netto):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(shippingNetPrice)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{totalVat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat7)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{totalVat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat19)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{currencyFormatter.format(priceCalculations.totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Versandkosten:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{currencyFormatter.format(deliveryCost)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
|
||||
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>Gesamtsumme:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
||||
{currencyFormatter.format(totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
) : (
|
||||
// Simple summary without shipping costs
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Gesamtnettopreis:</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalNet)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{priceCalculations.vat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat7)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{priceCalculations.vat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>19% Mehrwertsteuer:</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat19)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtbruttopreis ohne Versand:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={onClose}
|
||||
>
|
||||
Weiter einkaufen
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onCheckout && cartItems.length > 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
onClick={onCheckout}
|
||||
>
|
||||
Weiter zur Kasse
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CartDropdown;
|
||||
162
src/components/CartItem.js
Normal file
162
src/components/CartItem.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { Component } from 'react';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AddToCartButton from './AddToCartButton.js';
|
||||
|
||||
class CartItem extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
if (!window.tinyPicCache) {
|
||||
window.tinyPicCache = {};
|
||||
}
|
||||
if(this.props.item && this.props.item.pictureList && this.props.item.pictureList.split(',').length > 0) {
|
||||
const picid = this.props.item.pictureList.split(',')[0];
|
||||
if(window.tinyPicCache[picid]){
|
||||
this.setState({image:window.tinyPicCache[picid],loading:false, error: false})
|
||||
}else{
|
||||
this.setState({image: null, loading: true, error: false});
|
||||
if(this.props.socket){
|
||||
this.props.socket.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
|
||||
if(res.success){
|
||||
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
this.setState({image: window.tinyPicCache[picid], loading: false});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleIncrement = () => {
|
||||
const { item, onQuantityChange } = this.props;
|
||||
onQuantityChange(item.quantity + 1);
|
||||
};
|
||||
|
||||
handleDecrement = () => {
|
||||
const { item, onQuantityChange } = this.props;
|
||||
if (item.quantity > 1) {
|
||||
onQuantityChange(item.quantity - 1);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { item } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
alignItems="flex-start"
|
||||
sx={{ py: 2, width: '100%' }}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
alt={item.name}
|
||||
src={this.state?.image}
|
||||
sx={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
mr: 2,
|
||||
bgcolor: 'primary.light',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, width: '100%' }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="div"
|
||||
sx={{ fontWeight: 'bold', mb: 0.5 }}
|
||||
>
|
||||
<Link to={`/Artikel/${item.seoName}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
{item.name}
|
||||
</Link>
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1, mt: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
component="div"
|
||||
>
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(item.price)} x {item.quantity}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="primary.dark"
|
||||
fontWeight="bold"
|
||||
component="div"
|
||||
>
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(item.price * item.quantity)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Weight and VAT display - conditional layout based on weight */}
|
||||
{(item.weight > 0 || item.vat) && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: item.weight > 0 || (item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos') ? 'space-between' : 'flex-end',
|
||||
mb: 1
|
||||
}}>
|
||||
{item.weight > 0 && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
component="div"
|
||||
>
|
||||
{item.weight.toFixed(1).replace('.',',')} kg
|
||||
</Typography>
|
||||
)}
|
||||
{item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos' && (
|
||||
<Typography variant="body2" color="warning.main" fontWeight="medium" component="div">
|
||||
{item.versandklasse}
|
||||
</Typography>
|
||||
)}
|
||||
{item.vat && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
fontStyle="italic"
|
||||
component="div"
|
||||
>
|
||||
inkl. {new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(
|
||||
(item.price * item.quantity) - ((item.price * item.quantity) / (1 + item.vat / 100))
|
||||
)} MwSt. ({item.vat}%)
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ width: '250px'}}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
mb: 1,
|
||||
display: "block"
|
||||
}}
|
||||
>
|
||||
{this.props.id.toString().endsWith("steckling") ? "Lieferzeit: 14 Tage" :
|
||||
item.available == 1 ? "Lieferzeit: 2-3 Tage" :
|
||||
item.availableSupplier == 1 ? "Lieferzeit: 7-9 Tage" : ""}
|
||||
</Typography>
|
||||
<AddToCartButton available={1} id={this.props.id} availableSupplier={item.availableSupplier} price={item.price} seoName={item.seoName} name={item.name} weight={item.weight} vat={item.vat} versandklasse={item.versandklasse}/>
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CartItem;
|
||||
117
src/components/CartSyncDialog.js
Normal file
117
src/components/CartSyncDialog.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useState } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import RadioGroup from '@mui/material/RadioGroup';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
const CartSyncDialog = ({ open, localCart = [], serverCart = [], onClose, onConfirm }) => {
|
||||
const [option, setOption] = useState('merge');
|
||||
|
||||
// Helper function to determine if an item is selected in the result
|
||||
const isItemSelected = (item, cart, isResultCart = false) => {
|
||||
if (isResultCart) return true; // All items in result cart are selected
|
||||
|
||||
switch (option) {
|
||||
case 'deleteServer':
|
||||
return cart === localCart;
|
||||
case 'useServer':
|
||||
return cart === serverCart;
|
||||
case 'merge':
|
||||
return true; // Both carts contribute to merge
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const renderCartItem = (item, cart, isResultCart = false) => {
|
||||
const selected = isItemSelected(item, cart, isResultCart);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
sx={{
|
||||
opacity: selected ? 1 : 0.4,
|
||||
backgroundColor: selected ? 'action.selected' : 'transparent',
|
||||
borderRadius: 1,
|
||||
mb: 0.5
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{item.name} x {item.quantity}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
|
||||
<DialogTitle>Warenkorb-Synchronisierung</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography paragraph>
|
||||
Sie haben einen gespeicherten Warenkorb in ihrem Account. Bitte wählen Sie, wie Sie verfahren möchten:
|
||||
</Typography>
|
||||
<RadioGroup value={option} onChange={e => setOption(e.target.value)}>
|
||||
{/*<FormControlLabel
|
||||
value="useLocalArchive"
|
||||
control={<Radio />}
|
||||
label="Lokalen Warenkorb verwenden und Serverseitigen Warenkorb archivieren"
|
||||
/>*/}
|
||||
<FormControlLabel
|
||||
value="deleteServer"
|
||||
control={<Radio />}
|
||||
label="Server-Warenkorb löschen"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="useServer"
|
||||
control={<Radio />}
|
||||
label="Server-Warenkorb übernehmen"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="merge"
|
||||
control={<Radio />}
|
||||
label="Warenkörbe zusammenführen"
|
||||
/>
|
||||
</RadioGroup>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="h6">Ihr aktueller Warenkorb</Typography>
|
||||
<List sx={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
{localCart.length > 0
|
||||
? localCart.map(item => renderCartItem(item, localCart))
|
||||
: <Typography color="text.secondary" sx={{ p: 2 }}>leer</Typography>}
|
||||
</List>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="h6">In Ihrem Profil gespeicherter Warenkorb</Typography>
|
||||
<List sx={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
{serverCart.length > 0
|
||||
? serverCart.map(item => renderCartItem(item, serverCart))
|
||||
: <Typography color="text.secondary" sx={{ p: 2 }}>leer</Typography>}
|
||||
</List>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Abbrechen</Button>
|
||||
<Button variant="contained" onClick={() => onConfirm(option)}>
|
||||
Weiter
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartSyncDialog;
|
||||
201
src/components/CategoryBox.js
Normal file
201
src/components/CategoryBox.js
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
|
||||
// @note SwashingtonCP font is now loaded globally via index.css
|
||||
|
||||
// Initialize cache in window object if it doesn't exist
|
||||
if (typeof window !== 'undefined' && !window.categoryImageCache) {
|
||||
window.categoryImageCache = new Map();
|
||||
}
|
||||
|
||||
const CategoryBox = ({
|
||||
id,
|
||||
name,
|
||||
seoName,
|
||||
bgcolor,
|
||||
fontSize = '0.8rem',
|
||||
...props
|
||||
}) => {
|
||||
const [imageUrl, setImageUrl] = useState(null);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const socket = useContext(SocketContext);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl = null;
|
||||
|
||||
// Skip image loading entirely if prerender fallback is active
|
||||
// @note Check both browser and SSR environments for prerender flag
|
||||
const isPrerenderFallback = (typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
|
||||
(typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__);
|
||||
|
||||
if (isPrerenderFallback) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have the image data cached first
|
||||
if (typeof window !== 'undefined' && window.categoryImageCache.has(id)) {
|
||||
const cachedImageData = window.categoryImageCache.get(id);
|
||||
if (cachedImageData === null) {
|
||||
// @note Cached as null - this category has no image
|
||||
setImageUrl(null);
|
||||
setImageError(false);
|
||||
} else {
|
||||
// Create fresh blob URL from cached binary data
|
||||
try {
|
||||
const uint8Array = new Uint8Array(cachedImageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
} catch (error) {
|
||||
console.error('Error creating blob URL from cached data:', error);
|
||||
setImageError(true);
|
||||
setImageUrl(null);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If socket is available and connected, fetch the image
|
||||
if (socket && socket.connected && id && !isLoading) {
|
||||
setIsLoading(true);
|
||||
|
||||
socket.emit('getCategoryPic', { categoryId: id }, (response) => {
|
||||
setIsLoading(false);
|
||||
|
||||
if (response.success) {
|
||||
const imageData = response.image; // Binary image data or null
|
||||
|
||||
if (imageData) {
|
||||
try {
|
||||
// Convert binary data to blob URL
|
||||
const uint8Array = new Uint8Array(imageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
|
||||
// @note Cache the raw binary data in window object (not the blob URL)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.categoryImageCache.set(id, imageData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting image data to URL:', error);
|
||||
setImageError(true);
|
||||
setImageUrl(null);
|
||||
// Cache as null to avoid repeated requests
|
||||
if (typeof window !== 'undefined') {
|
||||
window.categoryImageCache.set(id, null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// @note No image available for this category
|
||||
setImageUrl(null);
|
||||
setImageError(false);
|
||||
// Cache as null so we don't keep requesting
|
||||
if (typeof window !== 'undefined') {
|
||||
window.categoryImageCache.set(id, null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Error fetching category image:', response.error);
|
||||
setImageError(true);
|
||||
setImageUrl(null);
|
||||
// Cache as null to avoid repeated failed requests
|
||||
if (typeof window !== 'undefined') {
|
||||
window.categoryImageCache.set(id, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up the object URL when component unmounts or image changes
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [socket, socket?.connected, id, isLoading]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
component={Link}
|
||||
to={`/Kategorie/${seoName}`}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
width: '130px',
|
||||
height: '130px',
|
||||
minHeight: '130px',
|
||||
minWidth: '130px',
|
||||
maxWidth: '130px',
|
||||
maxHeight: '130px',
|
||||
display: 'block',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
backgroundColor: bgcolor || '#f0f0f0',
|
||||
boxShadow: '0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)'
|
||||
}}
|
||||
sx={{
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: 8
|
||||
},
|
||||
...props.sx
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* Main content area - using flex to fill space */}
|
||||
<Box sx={{
|
||||
width: '130px',
|
||||
height: '130px',
|
||||
bgcolor: bgcolor || '#e0e0e0',
|
||||
position: 'relative',
|
||||
backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
|
||||
(typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__))
|
||||
? `url("/assets/images/cat${id}.jpg")`
|
||||
: (imageUrl && !imageError ? `url("${imageUrl}")` : 'none'),
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat'
|
||||
}}>
|
||||
|
||||
{/* Category name at bottom */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '0px',
|
||||
left: '0px',
|
||||
width: '130px',
|
||||
height: '40px',
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
display: 'table',
|
||||
tableLayout: 'fixed'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'table-cell',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
color: 'white',
|
||||
fontSize: fontSize,
|
||||
fontFamily: 'SwashingtonCP, "Times New Roman", Georgia, serif',
|
||||
fontWeight: 'normal',
|
||||
lineHeight: '1.2',
|
||||
padding: '0 8px'
|
||||
}}>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryBox;
|
||||
68
src/components/CategoryBoxGrid.js
Normal file
68
src/components/CategoryBoxGrid.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import CategoryBox from './CategoryBox.js';
|
||||
|
||||
// @note SwashingtonCP font is now loaded globally via index.css
|
||||
|
||||
const CategoryBoxGrid = ({
|
||||
categories = [],
|
||||
title,
|
||||
spacing = 3,
|
||||
showTitle = true,
|
||||
titleVariant = 'h3',
|
||||
titleSx = {},
|
||||
gridProps = {},
|
||||
boxProps = {}
|
||||
}) => {
|
||||
if (!categories || categories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Optional title */}
|
||||
{showTitle && title && (
|
||||
<Typography
|
||||
variant={titleVariant}
|
||||
component="h1"
|
||||
sx={{
|
||||
mb: 4,
|
||||
fontFamily: 'SwashingtonCP',
|
||||
color: 'primary.main',
|
||||
textAlign: 'center',
|
||||
...titleSx
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Category boxes grid */}
|
||||
<Grid container spacing={spacing} sx={{ mt: showTitle && title ? 0 : 2, ...gridProps.sx }} {...gridProps}>
|
||||
{categories.map((category) => (
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
lg={3}
|
||||
key={category.id}
|
||||
>
|
||||
<CategoryBox
|
||||
id={category.id}
|
||||
name={category.name}
|
||||
seoName={category.seoName}
|
||||
image={category.image}
|
||||
bgcolor={category.bgcolor}
|
||||
{...boxProps}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryBoxGrid;
|
||||
664
src/components/ChatAssistant.js
Normal file
664
src/components/ChatAssistant.js
Normal file
@@ -0,0 +1,664 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
|
||||
import parse, { domToReact } from 'html-react-parser';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { isUserLoggedIn } from './LoginComponent.js';
|
||||
// Initialize window object for storing messages
|
||||
if (!window.chatMessages) {
|
||||
window.chatMessages = [];
|
||||
}
|
||||
|
||||
class ChatAssistant extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const privacyConfirmed = sessionStorage.getItem('privacyConfirmed') === 'true';
|
||||
|
||||
this.state = {
|
||||
messages: window.chatMessages,
|
||||
inputValue: '',
|
||||
isTyping: false,
|
||||
isRecording: false,
|
||||
recordingTime: 0,
|
||||
mediaRecorder: null,
|
||||
audioChunks: [],
|
||||
aiThink: false,
|
||||
atDatabase: false,
|
||||
atWeb: false,
|
||||
privacyConfirmed: privacyConfirmed,
|
||||
isGuest: false
|
||||
};
|
||||
|
||||
this.messagesEndRef = React.createRef();
|
||||
this.fileInputRef = React.createRef();
|
||||
this.recordingTimer = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Add socket listeners if socket is available and connected
|
||||
this.addSocketListeners();
|
||||
|
||||
const userStatus = isUserLoggedIn();
|
||||
const isGuest = !userStatus.isLoggedIn;
|
||||
|
||||
if (isGuest && !this.state.privacyConfirmed) {
|
||||
this.setState(prevState => {
|
||||
if (prevState.messages.find(msg => msg.id === 'privacy-prompt')) {
|
||||
return { isGuest: true };
|
||||
}
|
||||
|
||||
const privacyMessage = {
|
||||
id: 'privacy-prompt',
|
||||
sender: 'bot',
|
||||
text: 'Bitte bestätigen Sie, dass Sie die <a href="/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> gelesen haben und damit einverstanden sind. <button data-confirm-privacy="true">Gelesen & Akzeptiert</button>',
|
||||
};
|
||||
const updatedMessages = [privacyMessage, ...prevState.messages];
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isGuest: true
|
||||
};
|
||||
});
|
||||
} else {
|
||||
this.setState({ isGuest });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected) {
|
||||
// Socket just connected, add listeners
|
||||
this.addSocketListeners();
|
||||
} else if (wasConnected && !isNowConnected) {
|
||||
// Socket just disconnected, remove listeners
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.removeSocketListeners();
|
||||
this.stopRecording();
|
||||
if (this.recordingTimer) {
|
||||
clearInterval(this.recordingTimer);
|
||||
}
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('aiassyResponse', this.handleBotResponse);
|
||||
this.props.socket.on('aiassyStatus', this.handleStateResponse);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('aiassyResponse', this.handleBotResponse);
|
||||
this.props.socket.off('aiassyStatus', this.handleStateResponse);
|
||||
}
|
||||
}
|
||||
|
||||
handleBotResponse = (msgId,response) => {
|
||||
this.setState(prevState => {
|
||||
// Check if a message with this msgId already exists
|
||||
const existingMessageIndex = prevState.messages.findIndex(msg => msg.msgId === msgId);
|
||||
|
||||
let updatedMessages;
|
||||
|
||||
if (existingMessageIndex !== -1 && msgId) {
|
||||
// If message with this msgId exists, append the response
|
||||
updatedMessages = [...prevState.messages];
|
||||
updatedMessages[existingMessageIndex] = {
|
||||
...updatedMessages[existingMessageIndex],
|
||||
text: updatedMessages[existingMessageIndex].text + response.content
|
||||
};
|
||||
} else {
|
||||
// Create a new message
|
||||
console.log('ChatAssistant: handleBotResponse', msgId, response);
|
||||
if(response && response.content) {
|
||||
const newBotMessage = {
|
||||
id: Date.now(),
|
||||
msgId: msgId,
|
||||
sender: 'bot',
|
||||
text: response.content,
|
||||
};
|
||||
updatedMessages = [...prevState.messages, newBotMessage];
|
||||
}
|
||||
}
|
||||
|
||||
// Store in window object
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isTyping: false
|
||||
};
|
||||
});
|
||||
}
|
||||
handleStateResponse = (msgId,response) => {
|
||||
if(response == 'think') this.setState({ aiThink: true });
|
||||
if(response == 'nothink') this.setState({ aiThink: false });
|
||||
if(response == 'database') this.setState({ atDatabase: true });
|
||||
if(response == 'nodatabase') this.setState({ atDatabase: false });
|
||||
if(response == 'web') this.setState({ atWeb: true });
|
||||
if(response == 'noweb') this.setState({ atWeb: false });
|
||||
}
|
||||
|
||||
scrollToBottom = () => {
|
||||
this.messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
handleInputChange = (event) => {
|
||||
this.setState({ inputValue: event.target.value });
|
||||
}
|
||||
|
||||
handleSendMessage = () => {
|
||||
const userMessage = this.state.inputValue.trim();
|
||||
if (!userMessage) return;
|
||||
|
||||
const newUserMessage = {
|
||||
id: Date.now(),
|
||||
sender: 'user',
|
||||
text: userMessage,
|
||||
};
|
||||
|
||||
// Update messages in component state
|
||||
this.setState(prevState => {
|
||||
const updatedMessages = [...prevState.messages, newUserMessage];
|
||||
// Store in window object
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
inputValue: '',
|
||||
isTyping: true
|
||||
};
|
||||
}, () => {
|
||||
// Emit message to socket server after state is updated
|
||||
if (userMessage.trim() && this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyMessage', userMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleKeyDown = (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
this.handleSendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
const audioChunks = [];
|
||||
|
||||
mediaRecorder.addEventListener("dataavailable", event => {
|
||||
audioChunks.push(event.data);
|
||||
});
|
||||
|
||||
mediaRecorder.addEventListener("stop", () => {
|
||||
if (audioChunks.length > 0) {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
|
||||
this.sendAudioMessage(audioBlob);
|
||||
}
|
||||
|
||||
// Stop all tracks on the stream to release the microphone
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
});
|
||||
|
||||
// Start recording
|
||||
mediaRecorder.start();
|
||||
|
||||
// Set up timer - limit to 60 seconds
|
||||
this.recordingTimer = setInterval(() => {
|
||||
this.setState(prevState => {
|
||||
const newTime = prevState.recordingTime + 1;
|
||||
|
||||
// Auto-stop after 10 seconds
|
||||
if (newTime >= 10) {
|
||||
this.stopRecording();
|
||||
}
|
||||
|
||||
return { recordingTime: newTime };
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
this.setState({
|
||||
isRecording: true,
|
||||
mediaRecorder,
|
||||
audioChunks,
|
||||
recordingTime: 0
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error accessing microphone:", err);
|
||||
alert("Could not access microphone. Please check your browser permissions.");
|
||||
}
|
||||
};
|
||||
|
||||
stopRecording = () => {
|
||||
const { mediaRecorder, isRecording } = this.state;
|
||||
|
||||
if (this.recordingTimer) {
|
||||
clearInterval(this.recordingTimer);
|
||||
}
|
||||
|
||||
if (mediaRecorder && isRecording) {
|
||||
mediaRecorder.stop();
|
||||
this.setState({
|
||||
isRecording: false,
|
||||
recordingTime: 0
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
sendAudioMessage = async (audioBlob) => {
|
||||
// Create a URL for the audio blob
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
// Create a user message with audio content
|
||||
const newUserMessage = {
|
||||
id: Date.now(),
|
||||
sender: 'user',
|
||||
text: `<audio controls src="${audioUrl}"></audio>`,
|
||||
isAudio: true
|
||||
};
|
||||
|
||||
// Update UI with the audio message
|
||||
this.setState(prevState => {
|
||||
const updatedMessages = [...prevState.messages, newUserMessage];
|
||||
// Store in window object
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isTyping: true
|
||||
};
|
||||
});
|
||||
|
||||
// Convert audio to base64 for sending to server
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(audioBlob);
|
||||
reader.onloadend = () => {
|
||||
const base64Audio = reader.result.split(',')[1];
|
||||
// Send audio data to server
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyAudioMessage', {
|
||||
audio: base64Audio,
|
||||
format: 'wav'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
handleImageUpload = () => {
|
||||
this.fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
handleFileChange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
this.resizeAndSendImage(file);
|
||||
}
|
||||
// Reset the file input
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
resizeAndSendImage = (file) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// Calculate new dimensions (max 450px width/height)
|
||||
const maxSize = 450;
|
||||
let { width, height } = img;
|
||||
|
||||
if (width > height) {
|
||||
if (width > maxSize) {
|
||||
height *= maxSize / width;
|
||||
width = maxSize;
|
||||
}
|
||||
} else {
|
||||
if (height > maxSize) {
|
||||
width *= maxSize / height;
|
||||
height = maxSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Set canvas dimensions
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Draw and compress image
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Convert to blob with compression
|
||||
canvas.toBlob((blob) => {
|
||||
this.sendImageMessage(blob);
|
||||
}, 'image/jpeg', 0.8);
|
||||
};
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
};
|
||||
|
||||
sendImageMessage = async (imageBlob) => {
|
||||
// Create a URL for the image blob
|
||||
const imageUrl = URL.createObjectURL(imageBlob);
|
||||
|
||||
// Create a user message with image content
|
||||
const newUserMessage = {
|
||||
id: Date.now(),
|
||||
sender: 'user',
|
||||
text: `<img src="${imageUrl}" alt="Uploaded image" style="max-width: 100%; height: auto; border-radius: 8px;" />`,
|
||||
isImage: true
|
||||
};
|
||||
|
||||
// Update UI with the image message
|
||||
this.setState(prevState => {
|
||||
const updatedMessages = [...prevState.messages, newUserMessage];
|
||||
// Store in window object
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isTyping: true
|
||||
};
|
||||
});
|
||||
|
||||
// Convert image to base64 for sending to server
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(imageBlob);
|
||||
reader.onloadend = () => {
|
||||
const base64Image = reader.result.split(',')[1];
|
||||
// Send image data to server
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyPicMessage', {
|
||||
image: base64Image,
|
||||
format: 'jpeg'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
formatTime = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
||||
};
|
||||
|
||||
handlePrivacyConfirm = () => {
|
||||
sessionStorage.setItem('privacyConfirmed', 'true');
|
||||
this.setState(prevState => {
|
||||
const updatedMessages = prevState.messages.filter(msg => msg.id !== 'privacy-prompt');
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
privacyConfirmed: true,
|
||||
messages: updatedMessages
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
formatMarkdown = (text) => {
|
||||
// Replace code blocks with formatted HTML
|
||||
return text.replace(/```(.*?)\n([\s\S]*?)```/g, (match, language, code) => {
|
||||
return `<pre class="code-block" data-language="${language.trim()}"><code>${code.trim()}</code></pre>`;
|
||||
});
|
||||
};
|
||||
|
||||
getParseOptions = () => ({
|
||||
replace: (domNode) => {
|
||||
// Convert <a> tags to React Router Links
|
||||
if (domNode.name === 'a' && domNode.attribs && domNode.attribs.href) {
|
||||
const href = domNode.attribs.href;
|
||||
|
||||
// Only convert internal links (not external URLs)
|
||||
if (!href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('//')) {
|
||||
return (
|
||||
<Link to={href} style={{ color: 'inherit', textDecoration: 'underline' }}>
|
||||
{domToReact(domNode.children, this.getParseOptions())}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Style pre/code blocks
|
||||
if (domNode.name === 'pre' && domNode.attribs && domNode.attribs.class === 'code-block') {
|
||||
const language = domNode.attribs['data-language'] || '';
|
||||
return (
|
||||
<pre style={{
|
||||
backgroundColor: '#c0f5c0',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
overflowX: 'auto',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.9em',
|
||||
whiteSpace: 'pre-wrap',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
{language && <div style={{ marginBottom: '4px', color: '#666' }}>{language}</div>}
|
||||
{domToReact(domNode.children, this.getParseOptions())}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (domNode.name === 'button' && domNode.attribs && domNode.attribs['data-confirm-privacy']) {
|
||||
return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>Gelesen & Akzeptiert</Button>;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
render() {
|
||||
const { open, onClose } = this.props;
|
||||
const { messages, inputValue, isTyping, isRecording, recordingTime, isGuest, privacyConfirmed } = this.state;
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputsDisabled = isGuest && !privacyConfirmed;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: { xs: 16, sm: 80 },
|
||||
right: { xs: 16, sm: 16 },
|
||||
left: { xs: 16, sm: 'auto' },
|
||||
top: { xs: 16, sm: 'auto' },
|
||||
width: { xs: 'calc(100vw - 32px)', sm: 450, md: 600, lg: 750 },
|
||||
height: { xs: 'calc(100vh - 32px)', sm: 600, md: 650, lg: 700 },
|
||||
maxWidth: { xs: 'none', sm: 450, md: 600, lg: 750 },
|
||||
maxHeight: { xs: 'calc(100vh - 72px)', sm: 600, md: 650, lg: 700 },
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 1300,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
borderTopLeftRadius: 'inherit',
|
||||
borderTopRightRadius: 'inherit',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="div">
|
||||
Assistent
|
||||
<Typography component="span" color={this.state.aiThink ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🧠</Typography>
|
||||
<Typography component="span" color={this.state.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography>
|
||||
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
|
||||
</Typography>
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: 'primary.contrastText' }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{messages &&messages.map((message) => (
|
||||
<Box
|
||||
key={message.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: message.sender === 'user' ? 'flex-end' : 'flex-start',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{message.sender === 'bot' && (
|
||||
<Avatar sx={{ bgcolor: 'primary.main', width: 30, height: 30 }}>
|
||||
<SmartToyIcon fontSize="small" />
|
||||
</Avatar>
|
||||
)}
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
py: 1,
|
||||
px: 3,
|
||||
borderRadius: 2,
|
||||
bgcolor: message.sender === 'user' ? 'secondary.light' : 'grey.200',
|
||||
maxWidth: '75%',
|
||||
fontSize: '0.8em'
|
||||
}}
|
||||
>
|
||||
{message.text ? parse(this.formatMarkdown(message.text), this.getParseOptions()) : ''}
|
||||
</Paper>
|
||||
{message.sender === 'user' && (
|
||||
<Avatar sx={{ bgcolor: 'secondary.main', width: 30, height: 30 }}>
|
||||
<PersonIcon fontSize="small" />
|
||||
</Avatar>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{isTyping && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar sx={{ bgcolor: 'primary.main', width: 30, height: 30 }}>
|
||||
<SmartToyIcon fontSize="small" />
|
||||
</Avatar>
|
||||
<Paper elevation={1} sx={{ p: 1, borderRadius: 2, bgcolor: 'grey.200', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<CircularProgress size={16} sx={{ mx: 1 }} />
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
<div ref={this.messagesEndRef} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
p: 1,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={this.fileInputRef}
|
||||
accept="image/*"
|
||||
onChange={this.handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."}
|
||||
value={inputValue}
|
||||
onChange={this.handleInputChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
disabled={isRecording || inputsDisabled}
|
||||
slotProps={{
|
||||
input: {
|
||||
maxLength: 300,
|
||||
endAdornment: isRecording && (
|
||||
<Typography variant="caption" color="primary" sx={{ mr: 1 }}>
|
||||
{this.formatTime(recordingTime)}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{isRecording ? (
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={this.stopRecording}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<StopIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={this.startRecording}
|
||||
sx={{ ml: 1 }}
|
||||
disabled={isTyping || inputsDisabled}
|
||||
>
|
||||
<MicIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={this.handleImageUpload}
|
||||
sx={{ ml: 1 }}
|
||||
disabled={isTyping || isRecording || inputsDisabled}
|
||||
>
|
||||
<PhotoCameraIcon />
|
||||
</IconButton>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ ml: 1 }}
|
||||
onClick={this.handleSendMessage}
|
||||
disabled={isTyping || isRecording || inputsDisabled}
|
||||
>
|
||||
Senden
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatAssistant;
|
||||
681
src/components/Content.js
Normal file
681
src/components/Content.js
Normal file
@@ -0,0 +1,681 @@
|
||||
import React, { Component } from 'react';
|
||||
import Container from '@mui/material/Container';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Link } from 'react-router-dom';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import ProductFilters from './ProductFilters.js';
|
||||
import ProductList from './ProductList.js';
|
||||
import CategoryBoxGrid from './CategoryBoxGrid.js';
|
||||
import CategoryBox from './CategoryBox.js';
|
||||
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// @note SwashingtonCP font is now loaded globally via index.css
|
||||
|
||||
const withRouter = (ClassComponent) => {
|
||||
return (props) => {
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
return <ClassComponent {...props} params={params} searchParams={searchParams} />;
|
||||
};
|
||||
};
|
||||
|
||||
function getCachedCategoryData(categoryId) {
|
||||
if (!window.productCache) {
|
||||
window.productCache = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `categoryProducts_${categoryId}`;
|
||||
const cachedData = window.productCache[cacheKey];
|
||||
|
||||
if (cachedData) {
|
||||
const { timestamp } = cachedData;
|
||||
const cacheAge = Date.now() - timestamp;
|
||||
const tenMinutes = 10 * 60 * 1000;
|
||||
if (cacheAge < tenMinutes) {
|
||||
return cachedData;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error reading from cache:', err);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function getFilteredProducts(unfilteredProducts, attributes) {
|
||||
const attributeSettings = getAllSettingsWithPrefix('filter_attribute_');
|
||||
const manufacturerSettings = getAllSettingsWithPrefix('filter_manufacturer_');
|
||||
const availabilitySettings = getAllSettingsWithPrefix('filter_availability_');
|
||||
|
||||
const attributeFilters = [];
|
||||
Object.keys(attributeSettings).forEach(key => {
|
||||
if (attributeSettings[key] === 'true') {
|
||||
attributeFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
const manufacturerFilters = [];
|
||||
Object.keys(manufacturerSettings).forEach(key => {
|
||||
if (manufacturerSettings[key] === 'true') {
|
||||
manufacturerFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
const availabilityFilters = [];
|
||||
Object.keys(availabilitySettings).forEach(key => {
|
||||
if (availabilitySettings[key] === 'true') {
|
||||
availabilityFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueAttributes = [...new Set((attributes || []).map(attr => attr.kMerkmalWert ? attr.kMerkmalWert.toString() : ''))];
|
||||
const uniqueManufacturers = [...new Set((unfilteredProducts || []).filter(product => product.manufacturerId).map(product => product.manufacturerId ? product.manufacturerId.toString() : ''))];
|
||||
const uniqueManufacturersWithName = [...new Set((unfilteredProducts || []).filter(product => product.manufacturerId).map(product => ({id:product.manufacturerId ? product.manufacturerId.toString() : '',value:product.manufacturer})))];
|
||||
const activeAttributeFilters = attributeFilters.filter(filter => uniqueAttributes.includes(filter));
|
||||
const activeManufacturerFilters = manufacturerFilters.filter(filter => uniqueManufacturers.includes(filter));
|
||||
const attributeFiltersByGroup = {};
|
||||
for (const filterId of activeAttributeFilters) {
|
||||
const attribute = attributes.find(attr => attr.kMerkmalWert.toString() === filterId);
|
||||
if (attribute) {
|
||||
if (!attributeFiltersByGroup[attribute.cName]) {
|
||||
attributeFiltersByGroup[attribute.cName] = [];
|
||||
}
|
||||
attributeFiltersByGroup[attribute.cName].push(filterId);
|
||||
}
|
||||
}
|
||||
|
||||
let filteredProducts = (unfilteredProducts || []).filter(product => {
|
||||
const availabilityFilter = sessionStorage.getItem('filter_availability');
|
||||
let inStockMatch = availabilityFilter == 1 ? true : (product.available>0);
|
||||
const isNewMatch = availabilityFilters.includes('2') ? isNew(product.neu) : true;
|
||||
let soonMatch = availabilityFilters.includes('3') ? !product.available && product.incoming : true;
|
||||
|
||||
const soon2Match = (availabilityFilter != 1)&&availabilityFilters.includes('3') ? (product.available) || (!product.available && product.incoming) : true;
|
||||
if( (availabilityFilter != 1)&&availabilityFilters.includes('3') && ((product.available) || (!product.available && product.incoming))){
|
||||
inStockMatch = true;
|
||||
soonMatch = true;
|
||||
console.log("soon2Match", product.cName);
|
||||
}
|
||||
|
||||
const manufacturerMatch = activeManufacturerFilters.length === 0 ||
|
||||
|
||||
(product.manufacturerId && activeManufacturerFilters.includes(product.manufacturerId.toString()));
|
||||
if (Object.keys(attributeFiltersByGroup).length === 0) {
|
||||
return manufacturerMatch && soon2Match && inStockMatch && soonMatch && isNewMatch;
|
||||
}
|
||||
const productAttributes = attributes
|
||||
.filter(attr => attr.kArtikel === product.id);
|
||||
const attributeMatch = Object.entries(attributeFiltersByGroup).every(([groupName, groupFilters]) => {
|
||||
const productGroupAttributes = productAttributes
|
||||
.filter(attr => attr.cName === groupName)
|
||||
.map(attr => attr.kMerkmalWert ? attr.kMerkmalWert.toString() : '');
|
||||
return groupFilters.some(filter => productGroupAttributes.includes(filter));
|
||||
});
|
||||
return manufacturerMatch && attributeMatch && soon2Match && inStockMatch && soonMatch && isNewMatch;
|
||||
});
|
||||
|
||||
|
||||
const activeAttributeFiltersWithNames = activeAttributeFilters.map(filter => {
|
||||
const attribute = attributes.find(attr => attr.kMerkmalWert.toString() === filter);
|
||||
return {name: attribute.cName, value: attribute.cWert, id: attribute.kMerkmalWert};
|
||||
});
|
||||
const activeManufacturerFiltersWithNames = activeManufacturerFilters.map(filter => {
|
||||
const manufacturer = uniqueManufacturersWithName.find(manufacturer => manufacturer.id === filter);
|
||||
return {name: manufacturer.value, value: manufacturer.id};
|
||||
});
|
||||
return {filteredProducts,activeAttributeFilters:activeAttributeFiltersWithNames,activeManufacturerFilters:activeManufacturerFiltersWithNames};
|
||||
}
|
||||
function setCachedCategoryData(categoryId, data) {
|
||||
if (!window.productCache) {
|
||||
window.productCache = {};
|
||||
}
|
||||
if (!window.productDetailCache) {
|
||||
window.productDetailCache = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `categoryProducts_${categoryId}`;
|
||||
if(data.products) for(const product of data.products) {
|
||||
window.productDetailCache[product.id] = product;
|
||||
}
|
||||
window.productCache[cacheKey] = {
|
||||
...data,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error writing to cache:', err);
|
||||
}
|
||||
}
|
||||
|
||||
class Content extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loaded: false,
|
||||
categoryName: null,
|
||||
unfilteredProducts: [],
|
||||
filteredProducts: [],
|
||||
attributes: [],
|
||||
childCategories: []
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if(this.props.params.categoryId) {this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
})}
|
||||
else if (this.props.searchParams?.get('q')) {
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if(this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId)) {
|
||||
window.currentSearchQuery = null;
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
});
|
||||
}
|
||||
else if (this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'))) {
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
})
|
||||
}
|
||||
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected && !this.state.loaded) {
|
||||
// Socket just connected and we haven't loaded data yet, retry loading
|
||||
if (this.props.params.categoryId) {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
} else if (this.props.searchParams?.get('q')) {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processData(response) {
|
||||
const unfilteredProducts = response.products;
|
||||
|
||||
if (!window.individualProductCache) {
|
||||
window.individualProductCache = {};
|
||||
}
|
||||
//console.log("processData", unfilteredProducts);
|
||||
if(unfilteredProducts) unfilteredProducts.forEach(product => {
|
||||
window.individualProductCache[product.id] = {
|
||||
data: product,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
});
|
||||
|
||||
this.setState({
|
||||
unfilteredProducts: unfilteredProducts,
|
||||
...getFilteredProducts(
|
||||
unfilteredProducts,
|
||||
response.attributes
|
||||
),
|
||||
categoryName: response.categoryName || response.name || null,
|
||||
dataType: response.dataType,
|
||||
dataParam: response.dataParam,
|
||||
attributes: response.attributes,
|
||||
childCategories: response.childCategories || [],
|
||||
loaded: true
|
||||
});
|
||||
}
|
||||
|
||||
fetchCategoryData(categoryId) {
|
||||
const cachedData = getCachedCategoryData(categoryId);
|
||||
if (cachedData) {
|
||||
this.processDataWithCategoryTree(cachedData, categoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch category data");
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit("getCategoryProducts", { categoryId: categoryId },
|
||||
(response) => {
|
||||
setCachedCategoryData(categoryId, response);
|
||||
if (response && response.products !== undefined) {
|
||||
this.processDataWithCategoryTree(response, categoryId);
|
||||
} else {
|
||||
console.log("fetchCategoryData in Content failed", response);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
processDataWithCategoryTree(response, categoryId) {
|
||||
// Get child categories from the cached category tree
|
||||
let childCategories = [];
|
||||
try {
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (categoryTreeCache && categoryTreeCache.categoryTree) {
|
||||
// If categoryId is a string (SEO name), find by seoName, otherwise by ID
|
||||
const targetCategory = typeof categoryId === 'string'
|
||||
? this.findCategoryBySeoName(categoryTreeCache.categoryTree, categoryId)
|
||||
: this.findCategoryById(categoryTreeCache.categoryTree, categoryId);
|
||||
|
||||
if (targetCategory && targetCategory.children) {
|
||||
childCategories = targetCategory.children;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error getting child categories from tree:', err);
|
||||
}
|
||||
|
||||
// Add child categories to the response
|
||||
const enhancedResponse = {
|
||||
...response,
|
||||
childCategories
|
||||
};
|
||||
|
||||
this.processData(enhancedResponse);
|
||||
}
|
||||
|
||||
findCategoryById(category, targetId) {
|
||||
if (!category) return null;
|
||||
|
||||
if (category.id === targetId) {
|
||||
return category;
|
||||
}
|
||||
|
||||
if (category.children) {
|
||||
for (let child of category.children) {
|
||||
const found = this.findCategoryById(child, targetId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fetchSearchData(query) {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch search data");
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit("getSearchProducts", { query },
|
||||
(response) => {
|
||||
if (response && response.products) {
|
||||
this.processData(response);
|
||||
} else {
|
||||
console.log("fetchSearchData in Content failed", response);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
filterProducts() {
|
||||
this.setState({
|
||||
...getFilteredProducts(
|
||||
this.state.unfilteredProducts,
|
||||
this.state.attributes
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to find category by seoName
|
||||
findCategoryBySeoName = (categoryNode, seoName) => {
|
||||
if (!categoryNode) return null;
|
||||
|
||||
if (categoryNode.seoName === seoName) {
|
||||
return categoryNode;
|
||||
}
|
||||
|
||||
if (categoryNode.children) {
|
||||
for (const child of categoryNode.children) {
|
||||
const found = this.findCategoryBySeoName(child, seoName);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to get current category ID from seoName
|
||||
getCurrentCategoryId = () => {
|
||||
const seoName = this.props.params.categoryId;
|
||||
|
||||
// Get the category tree from cache
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the category by seoName
|
||||
const category = this.findCategoryBySeoName(categoryTreeCache.categoryTree, seoName);
|
||||
return category ? category.id : null;
|
||||
}
|
||||
|
||||
renderParentCategoryNavigation = () => {
|
||||
const currentCategoryId = this.getCurrentCategoryId();
|
||||
if (!currentCategoryId) return null;
|
||||
|
||||
// Get the category tree from cache
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the current category in the tree
|
||||
const currentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategoryId);
|
||||
if (!currentCategory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this category has a parent (not root category 209)
|
||||
if (!currentCategory.parentId || currentCategory.parentId === 209) {
|
||||
return null; // Don't show for top-level categories
|
||||
}
|
||||
|
||||
// Find the parent category
|
||||
const parentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategory.parentId);
|
||||
if (!parentCategory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create parent category object for CategoryBox
|
||||
const parentCategoryForDisplay = {
|
||||
id: parentCategory.id,
|
||||
seoName: parentCategory.seoName,
|
||||
name: parentCategory.name,
|
||||
image: parentCategory.image,
|
||||
isParentNav: true
|
||||
};
|
||||
|
||||
return parentCategoryForDisplay;
|
||||
}
|
||||
|
||||
render() {
|
||||
// Check if we should show category boxes instead of product list
|
||||
const showCategoryBoxes = this.state.loaded &&
|
||||
this.state.unfilteredProducts.length === 0 &&
|
||||
this.state.childCategories.length > 0;
|
||||
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 2, flexGrow: 1, display: 'grid', gridTemplateRows: '1fr' }}>
|
||||
|
||||
{showCategoryBoxes ? (
|
||||
// Show category boxes layout when no products but have child categories
|
||||
<CategoryBoxGrid
|
||||
categories={this.state.childCategories}
|
||||
title={this.state.categoryName}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Show subcategories above main layout when there are both products and child categories */}
|
||||
{this.state.loaded &&
|
||||
this.state.unfilteredProducts.length > 0 &&
|
||||
this.state.childCategories.length > 0 && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
{(() => {
|
||||
const parentCategory = this.renderParentCategoryNavigation();
|
||||
if (parentCategory) {
|
||||
// Show parent category to the left of subcategories
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 3, flexWrap: 'wrap' }}>
|
||||
{/* Parent Category Box */}
|
||||
<Box sx={{ mt:2,position: 'relative', flexShrink: 0 }}>
|
||||
<CategoryBox
|
||||
id={parentCategory.id}
|
||||
seoName={parentCategory.seoName}
|
||||
name={parentCategory.name}
|
||||
image={parentCategory.image}
|
||||
height={130}
|
||||
fontSize="1.0rem"
|
||||
/>
|
||||
{/* Up Arrow Overlay */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
bgcolor: 'rgba(27, 94, 32, 0.8)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 100,
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Subcategories Grid */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<CategoryBoxGrid
|
||||
categories={this.state.childCategories}
|
||||
showTitle={false}
|
||||
spacing={3}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
// Just show subcategories without parent
|
||||
return (
|
||||
<CategoryBoxGrid
|
||||
categories={this.state.childCategories}
|
||||
showTitle={false}
|
||||
spacing={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Show parent category navigation when in 2nd or 3rd level but no subcategories */}
|
||||
{this.state.loaded &&
|
||||
this.props.params.categoryId &&
|
||||
!(this.state.unfilteredProducts.length > 0 && this.state.childCategories.length > 0) && (() => {
|
||||
const parentCategory = this.renderParentCategoryNavigation();
|
||||
if (parentCategory) {
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ position: 'relative', width: 'fit-content' }}>
|
||||
<CategoryBox
|
||||
id={parentCategory.id}
|
||||
seoName={parentCategory.seoName}
|
||||
name={parentCategory.name}
|
||||
image={parentCategory.image}
|
||||
height={130}
|
||||
fontSize="1.0rem"
|
||||
/>
|
||||
{/* Up Arrow Overlay */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
bgcolor: 'rgba(27, 94, 32, 0.8)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 100,
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Show normal product list layout */}
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 2fr', md: '1fr 3fr', lg: '1fr 4fr', xl: '1fr 4fr' },
|
||||
gap: 3
|
||||
}}>
|
||||
|
||||
<Stack direction="row" spacing={0} sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: { xs: 'min-content', sm: '100%' }
|
||||
}}>
|
||||
|
||||
<Box >
|
||||
|
||||
<ProductFilters
|
||||
products={this.state.unfilteredProducts}
|
||||
filteredProducts={this.state.filteredProducts}
|
||||
attributes={this.state.attributes}
|
||||
searchParams={this.props.searchParams}
|
||||
onFilterChange={()=>{this.filterProducts()}}
|
||||
dataType={this.state.dataType}
|
||||
dataParam={this.state.dataParam}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{(this.getCurrentCategoryId() == 706 || this.getCurrentCategoryId() == 689) &&
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
|
||||
<Typography variant="h6" sx={{mt:3}}>
|
||||
Andere Kategorien
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
|
||||
{this.props.params.categoryId == 'Stecklinge' && <Paper
|
||||
component={Link}
|
||||
to="/Kategorie/Seeds"
|
||||
sx={{
|
||||
p:0,
|
||||
mt: 1,
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
height: 300,
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: 10,
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: 20
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Image Container - Place your seeds image here */}
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
bgcolor: '#e1f0d3',
|
||||
backgroundImage: 'url("/assets/images/seeds.jpg")',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* Overlay text - optional */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bgcolor: 'rgba(27, 94, 32, 0.8)',
|
||||
p: 2,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
|
||||
Seeds
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
}
|
||||
|
||||
{this.props.params.categoryId == 'Seeds' && <Paper
|
||||
component={Link}
|
||||
to="/Kategorie/Stecklinge"
|
||||
sx={{
|
||||
p: 0,
|
||||
mt: 1,
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
height: 300,
|
||||
boxShadow: 10,
|
||||
transition: 'all 0.3s ease',
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: 20
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Image Container - Place your cutlings image here */}
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
bgcolor: '#e8f5d6',
|
||||
backgroundImage: 'url("/assets/images/cutlings.jpg")',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* Overlay text - optional */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bgcolor: 'rgba(27, 94, 32, 0.8)',
|
||||
p: 2,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
|
||||
Stecklinge
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>}
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<ProductList
|
||||
socket={this.props.socket}
|
||||
totalProductCount={(this.state.unfilteredProducts || []).length}
|
||||
products={this.state.filteredProducts || []}
|
||||
activeAttributeFilters={this.state.activeAttributeFilters || []}
|
||||
activeManufacturerFilters={this.state.activeManufacturerFilters || []}
|
||||
onFilterChange={()=>{this.filterProducts()}}
|
||||
dataType={this.state.dataType}
|
||||
dataParam={this.state.dataParam}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Content);
|
||||
319
src/components/Filter.js
Normal file
319
src/components/Filter.js
Normal file
@@ -0,0 +1,319 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
class Filter extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const options = this.initializeOptions(props);
|
||||
const counts = this.initializeCounts(props,options);
|
||||
this.state = {
|
||||
options,
|
||||
counts,
|
||||
isCollapsed: true // Start collapsed on xs screens
|
||||
};
|
||||
}
|
||||
|
||||
initializeCounts = (props,options) => {
|
||||
const counts = {};
|
||||
|
||||
if(props.filterType === 'availability'){
|
||||
const products = options[1] ? props.products : props.products;
|
||||
if(products) for(const product of products){
|
||||
if(product.available) counts[1] = (counts[1] || 0) + 1;
|
||||
if(isNew(product.neu)) counts[2] = (counts[2] || 0) + 1;
|
||||
if(!product.available && product.incoming) counts[3] = (counts[3] || 0) + 1;
|
||||
}
|
||||
}
|
||||
if(props.filterType === 'manufacturer'){
|
||||
const uniqueManufacturers = [...new Set(props.products.filter(product => product.manufacturerId).map(product => product.manufacturerId))];
|
||||
const filteredManufacturers = uniqueManufacturers.filter(manufacturerId => options[manufacturerId] === true);
|
||||
const products = filteredManufacturers.length > 0 ? props.products : props.filteredProducts;
|
||||
for(const product of products){
|
||||
counts[product.manufacturerId] = (counts[product.manufacturerId] || 0) + 1;
|
||||
}
|
||||
}
|
||||
if(props.filterType === 'attribute'){
|
||||
//console.log('countCaclulation for attribute filter',props.title,this.props.title);
|
||||
const optionIds = props.options.map(option => option.id);
|
||||
//console.log('optionIds',optionIds);
|
||||
const attributeCount = {};
|
||||
for(const attribute of props.attributes){
|
||||
attributeCount[attribute.kMerkmalWert] = (attributeCount[attribute.kMerkmalWert] || 0) + 1;
|
||||
}
|
||||
const uniqueProductIds = props.filteredProducts.map(product => product.id);
|
||||
const attributesFilteredByUniqueAttributeProducts = props.attributes.filter(attribute => uniqueProductIds.includes(attribute.kArtikel));
|
||||
const attributeCountFiltered = {};
|
||||
for(const attribute of attributesFilteredByUniqueAttributeProducts){
|
||||
attributeCountFiltered[attribute.kMerkmalWert] = (attributeCountFiltered[attribute.kMerkmalWert] || 0) + 1;
|
||||
}
|
||||
let oneIsSelected = false;
|
||||
for(const option of optionIds) if(options[option]) oneIsSelected = true;
|
||||
for(const option of props.options){
|
||||
counts[option.id] = oneIsSelected?attributeCount[option.id]:attributeCountFiltered[option.id];
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
initializeOptions = (props) => {
|
||||
|
||||
if(props.filterType === 'attribute'){
|
||||
const attributeFilters = [];
|
||||
const attributeSettings = getAllSettingsWithPrefix('filter_attribute_');
|
||||
|
||||
Object.keys(attributeSettings).forEach(key => {
|
||||
if (attributeSettings[key] === 'true') {
|
||||
attributeFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
return attributeFilters.reduce((acc, filter) => {
|
||||
acc[filter] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
if(props.filterType === 'manufacturer'){
|
||||
const manufacturerFilters = [];
|
||||
const manufacturerSettings = getAllSettingsWithPrefix('filter_manufacturer_');
|
||||
|
||||
Object.keys(manufacturerSettings).forEach(key => {
|
||||
if (manufacturerSettings[key] === 'true') {
|
||||
manufacturerFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
return manufacturerFilters.reduce((acc, filter) => {
|
||||
acc[filter] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
if(props.filterType === 'availability'){
|
||||
const availabilityFilter = sessionStorage.getItem('filter_availability');
|
||||
const newFilters = [];
|
||||
const soonFilters = [];
|
||||
const availabilitySettings = getAllSettingsWithPrefix('filter_availability_');
|
||||
|
||||
Object.keys(availabilitySettings).forEach(key => {
|
||||
if (availabilitySettings[key] === 'true') {
|
||||
if(key.split('_')[2] == '2') newFilters.push(key.split('_')[2]);
|
||||
if(key.split('_')[2] == '3') soonFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
//console.log('newFilters',newFilters);
|
||||
const optionsState = {};
|
||||
if(!availabilityFilter) optionsState['1'] = true;
|
||||
if(newFilters.length > 0) optionsState['2'] = true;
|
||||
if(soonFilters.length > 0) optionsState['3'] = true;
|
||||
|
||||
const inStock = props.searchParams?.get('inStock');
|
||||
if(inStock) optionsState[inStock] = true;
|
||||
return optionsState;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// make this more fine grained with dependencies on props
|
||||
|
||||
if((prevProps.products !== this.props.products) || (prevProps.filteredProducts !== this.props.filteredProducts) || (prevProps.options !== this.props.options) || (prevProps.attributes !== this.props.attributes)){
|
||||
const options = this.initializeOptions(this.props);
|
||||
const counts = this.initializeCounts(this.props,options);
|
||||
this.setState({
|
||||
options,
|
||||
counts
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleOptionChange = (event) => {
|
||||
const { name, checked } = event.target;
|
||||
|
||||
// Update local state first to ensure immediate UI feedback
|
||||
this.setState(prevState => ({
|
||||
options: {
|
||||
...prevState.options,
|
||||
[name]: checked
|
||||
}
|
||||
}));
|
||||
|
||||
// Then notify the parent component
|
||||
if (this.props.onFilterChange) {
|
||||
this.props.onFilterChange({
|
||||
type: this.props.filterType || 'default',
|
||||
name: name,
|
||||
value: checked
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
resetFilters = () => {
|
||||
// Reset current filter's state
|
||||
const emptyOptions = {};
|
||||
Object.keys(this.state.options).forEach(option => {
|
||||
emptyOptions[option] = false;
|
||||
});
|
||||
|
||||
this.setState({ options: emptyOptions });
|
||||
|
||||
// Notify parent component to reset ALL filters (including other filter components)
|
||||
if (this.props.onFilterChange) {
|
||||
this.props.onFilterChange({
|
||||
type: 'RESET_ALL_FILTERS',
|
||||
resetAll: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
toggleCollapse = () => {
|
||||
this.setState(prevState => ({
|
||||
isCollapsed: !prevState.isCollapsed
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { options, counts, isCollapsed } = this.state;
|
||||
const { title, options: optionsList = [] } = this.props;
|
||||
|
||||
// Check if we're on xs screen size
|
||||
const isXsScreen = window.innerWidth < 600;
|
||||
|
||||
const tableStyle = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse'
|
||||
};
|
||||
|
||||
const cellStyle = {
|
||||
padding: '0px 0',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: '1'
|
||||
};
|
||||
|
||||
const checkboxCellStyle = {
|
||||
...cellStyle,
|
||||
width: '20px',
|
||||
verticalAlign: 'middle',
|
||||
paddingRight: '8px'
|
||||
};
|
||||
|
||||
const labelCellStyle = {
|
||||
...cellStyle,
|
||||
cursor: 'pointer',
|
||||
verticalAlign: 'middle',
|
||||
userSelect: 'none'
|
||||
};
|
||||
|
||||
const countCellStyle = {
|
||||
...cellStyle,
|
||||
textAlign: 'right',
|
||||
color: 'rgba(0, 0, 0, 0.6)',
|
||||
fontSize: '1rem',
|
||||
verticalAlign: 'middle'
|
||||
};
|
||||
|
||||
const countBoxStyle = {
|
||||
display: 'inline-block',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.7rem',
|
||||
minWidth: '16px',
|
||||
textAlign: 'center',
|
||||
color: 'rgba(0, 0, 0, 0.7)'
|
||||
};
|
||||
|
||||
const resetButtonStyle = {
|
||||
padding: '2px 8px',
|
||||
fontSize: '0.7rem',
|
||||
backgroundColor: '#f0f0f0',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
color: 'rgba(0, 0, 0, 0.7)',
|
||||
float: 'right'
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: { xs: 'pointer', sm: 'default' }
|
||||
}}
|
||||
onClick={isXsScreen ? this.toggleCollapse : undefined}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="medium" gutterBottom={!isXsScreen}>
|
||||
{title}
|
||||
{/* Only show reset button on Availability filter */}
|
||||
{title === "VerfügbarkeitDISABLED" && (
|
||||
<button
|
||||
style={resetButtonStyle}
|
||||
onClick={this.resetFilters}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</Typography>
|
||||
{isXsScreen && (
|
||||
<IconButton size="small" sx={{ p: 0 }}>
|
||||
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Collapse in={!isXsScreen || !isCollapsed}>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<table style={tableStyle}>
|
||||
<tbody>
|
||||
{optionsList.map((option) => (
|
||||
<tr key={option.id} style={{ height: '32px' }}>
|
||||
<td style={checkboxCellStyle}>
|
||||
<Checkbox
|
||||
checked={options[option.id] || false}
|
||||
onChange={this.handleOptionChange}
|
||||
name={option.id}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{
|
||||
padding: '0px',
|
||||
'& .MuiSvgIcon-root': { fontSize: 28 }
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td style={labelCellStyle} onClick={() => {
|
||||
const event = { target: { name: option.id, checked: !options[option.id] } };
|
||||
this.handleOptionChange(event);
|
||||
}}>
|
||||
{option.name}
|
||||
</td>
|
||||
<td style={countCellStyle}>
|
||||
{counts && counts[option.id] !== undefined && (
|
||||
<span style={countBoxStyle}>
|
||||
{counts[option.id]}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Filter;
|
||||
354
src/components/Footer.js
Normal file
354
src/components/Footer.js
Normal file
@@ -0,0 +1,354 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Link from '@mui/material/Link';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Paper from '@mui/material/Paper';
|
||||
|
||||
// Styled component for the router links
|
||||
const StyledRouterLink = styled(RouterLink)(() => ({
|
||||
color: 'inherit',
|
||||
fontSize: '13px',
|
||||
textDecoration: 'none',
|
||||
lineHeight: '1.5',
|
||||
display: 'block',
|
||||
padding: '4px 8px',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}));
|
||||
|
||||
// Styled component for the domain link
|
||||
const StyledDomainLink = styled(Link)(() => ({
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
lineHeight: '1.5',
|
||||
'&:hover': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
}));
|
||||
|
||||
// Styled component for the dark overlay
|
||||
const DarkOverlay = styled(Box)(() => ({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
zIndex: 9998,
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 0.9s ease',
|
||||
}));
|
||||
|
||||
// Styled component for the info bubble
|
||||
const InfoBubble = styled(Paper)(({ theme }) => ({
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
padding: theme.spacing(3),
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
minWidth: '280px',
|
||||
maxWidth: '400px',
|
||||
textAlign: 'center',
|
||||
transition: 'all 0.9s ease',
|
||||
}));
|
||||
|
||||
class Footer extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showMapsInfo: false,
|
||||
showReviewsInfo: false,
|
||||
};
|
||||
}
|
||||
|
||||
handleMapsMouseEnter = () => {
|
||||
this.setState({ showMapsInfo: true });
|
||||
};
|
||||
|
||||
handleMapsMouseLeave = () => {
|
||||
this.setState({ showMapsInfo: false });
|
||||
};
|
||||
|
||||
handleReviewsMouseEnter = () => {
|
||||
this.setState({ showReviewsInfo: true });
|
||||
};
|
||||
|
||||
handleReviewsMouseLeave = () => {
|
||||
this.setState({ showReviewsInfo: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { showMapsInfo, showReviewsInfo } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dark overlay for Maps */}
|
||||
<DarkOverlay sx={{
|
||||
opacity: showMapsInfo ? 1 : 0
|
||||
}} />
|
||||
|
||||
{/* Dark overlay for Reviews */}
|
||||
<DarkOverlay sx={{
|
||||
opacity: showReviewsInfo ? 1 : 0
|
||||
}} />
|
||||
|
||||
{/* Info bubble */}
|
||||
<InfoBubble
|
||||
elevation={8}
|
||||
sx={{
|
||||
opacity: showMapsInfo ? 1 : 0,
|
||||
visibility: showMapsInfo ? 'visible' : 'hidden',
|
||||
transform: showMapsInfo ? 'translate(-50%, -50%) scale(1)' : 'translate(-50%, -50%) scale(0.8)'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
color: 'primary.main',
|
||||
fontSize: '1.25rem'
|
||||
}}
|
||||
>
|
||||
Filiale
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 1,
|
||||
color: 'text.primary'
|
||||
}}
|
||||
>
|
||||
Öffnungszeiten:
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Mo-Fr 10-20
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Sa 11-19
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 1,
|
||||
color: 'text.primary'
|
||||
}}
|
||||
>
|
||||
Trachenberger Straße 14 - Dresden
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontStyle: 'italic',
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Zwischen Haltepunkt Pieschen und Trachenberger Platz
|
||||
</Typography>
|
||||
</InfoBubble>
|
||||
|
||||
{/* Reviews Info bubble */}
|
||||
<InfoBubble
|
||||
elevation={8}
|
||||
sx={{
|
||||
opacity: showReviewsInfo ? 1 : 0,
|
||||
visibility: showReviewsInfo ? 'visible' : 'hidden',
|
||||
transform: showReviewsInfo ? 'translate(-50%, -50%) scale(1)' : 'translate(-50%, -50%) scale(0.8)',
|
||||
width: 'auto',
|
||||
minWidth: 'auto',
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '90vh',
|
||||
padding: 2
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/reviews.jpg"
|
||||
alt="Customer Reviews"
|
||||
sx={{
|
||||
width: '861px',
|
||||
height: '371px',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '80vh',
|
||||
borderRadius: '8px',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
</InfoBubble>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
py: 2,
|
||||
px: 2,
|
||||
mt: 'auto',
|
||||
mb: 0,
|
||||
backgroundColor: 'primary.dark',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
sx={{ filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))',maxWidth: 'md', margin: 'auto' }}
|
||||
spacing={{ xs: 3, md: 2 }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'center', md: 'flex-end' }}
|
||||
>
|
||||
{/* Legal Links Section */}
|
||||
<Stack
|
||||
direction={{ xs: 'row', md: 'column' }}
|
||||
spacing={{ xs: 2, md: 0.5 }}
|
||||
justifyContent="center"
|
||||
alignItems={{ xs: 'center', md: 'left' }}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<StyledRouterLink to="/datenschutz">Datenschutz</StyledRouterLink>
|
||||
<StyledRouterLink to="/agb">AGB</StyledRouterLink>
|
||||
<StyledRouterLink to="/sitemap">Sitemap</StyledRouterLink>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'row', md: 'column' }}
|
||||
spacing={{ xs: 2, md: 0.5 }}
|
||||
justifyContent="center"
|
||||
alignItems={{ xs: 'center', md: 'left' }}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<StyledRouterLink to="/impressum">Impressum</StyledRouterLink>
|
||||
<StyledRouterLink to="/batteriegesetzhinweise">Batteriegesetzhinweise</StyledRouterLink>
|
||||
<StyledRouterLink to="/widerrufsrecht">Widerrufsrecht</StyledRouterLink>
|
||||
</Stack>
|
||||
|
||||
{/* Payment Methods Section */}
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={1}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={{ xs: 1, md: 2 }}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Box component="img" src="/assets/images/cards.png" alt="Cash" sx={{ height: { xs: 80, md: 95 } }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Google Services Badge Section */}
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={1}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={{ xs: 1, md: 2 }}
|
||||
sx={{pb: '10px'}}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Link
|
||||
href="https://reviewthis.biz/growheads"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
zIndex: 9999
|
||||
}}
|
||||
onMouseEnter={this.handleReviewsMouseEnter}
|
||||
onMouseLeave={this.handleReviewsMouseLeave}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/gg.png"
|
||||
alt="Google Reviews"
|
||||
sx={{
|
||||
height: { xs: 50, md: 60 },
|
||||
cursor: 'pointer',
|
||||
transition: 'all 2s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.5) translateY(-10px)'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://maps.app.goo.gl/D67ewDU3dZBda1BUA"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
zIndex: 9999
|
||||
}}
|
||||
onMouseEnter={this.handleMapsMouseEnter}
|
||||
onMouseLeave={this.handleMapsMouseLeave}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/maps.png"
|
||||
alt="Google Maps"
|
||||
sx={{
|
||||
height: { xs: 40, md: 50 },
|
||||
cursor: 'pointer',
|
||||
transition: 'all 2s ease',
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.5) translateY(-10px)',
|
||||
filter: 'drop-shadow(0 8px 16px rgba(0, 0, 0, 0.4))'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Copyright Section */}
|
||||
<Box sx={{ pb:'20px',textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
||||
* Alle Preise inkl. gesetzlicher USt., zzgl. Versand
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
||||
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
208
src/components/GoogleLoginButton.js
Normal file
208
src/components/GoogleLoginButton.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { Component } from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import GoogleAuthContext from '../contexts/GoogleAuthContext.js';
|
||||
|
||||
class GoogleLoginButton extends Component {
|
||||
static contextType = GoogleAuthContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isInitialized: false,
|
||||
isInitializing: false,
|
||||
promptShown: false,
|
||||
isPrompting: false // @note Added to prevent multiple simultaneous prompts
|
||||
};
|
||||
this.promptTimeout = null; // @note Added to track timeout
|
||||
this.prevContextLoaded = false; // @note Track previous context loaded state
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Check if Google libraries are already available
|
||||
const hasGoogleLoaded = window.google && window.google.accounts && window.google.accounts.id;
|
||||
const contextLoaded = this.context && this.context.isLoaded;
|
||||
|
||||
// @note Initialize the tracked context loaded state
|
||||
this.prevContextLoaded = contextLoaded;
|
||||
|
||||
// @note Only initialize immediately if context is already loaded, otherwise let componentDidUpdate handle it
|
||||
if (hasGoogleLoaded && this.context.clientId && contextLoaded) {
|
||||
this.initializeGoogleSignIn();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
// Initialize when all conditions are met and we haven't initialized before
|
||||
const hasGoogleLoaded = window.google && window.google.accounts && window.google.accounts.id;
|
||||
const contextLoaded = this.context && this.context.isLoaded;
|
||||
|
||||
// @note Only initialize when context becomes loaded for the first time
|
||||
if (!this.state.isInitialized &&
|
||||
!this.state.isInitializing &&
|
||||
hasGoogleLoaded &&
|
||||
this.context.clientId &&
|
||||
contextLoaded &&
|
||||
!this.prevContextLoaded) {
|
||||
this.initializeGoogleSignIn();
|
||||
}
|
||||
|
||||
// @note Update the tracked context loaded state
|
||||
this.prevContextLoaded = contextLoaded;
|
||||
|
||||
// Auto-prompt if initialization is complete and autoInitiate is true
|
||||
if (this.props.autoInitiate &&
|
||||
this.state.isInitialized &&
|
||||
!this.state.promptShown &&
|
||||
!this.state.isPrompting && // @note Added check to prevent multiple prompts
|
||||
(!prevState.isInitialized || !prevProps.autoInitiate)) {
|
||||
this.setState({ promptShown: true });
|
||||
this.schedulePrompt(100);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// @note Clear timeout on unmount to prevent memory leaks
|
||||
if (this.promptTimeout) {
|
||||
clearTimeout(this.promptTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
schedulePrompt = (delay = 0) => {
|
||||
// @note Clear any existing timeout
|
||||
if (this.promptTimeout) {
|
||||
clearTimeout(this.promptTimeout);
|
||||
}
|
||||
|
||||
this.promptTimeout = setTimeout(() => {
|
||||
this.tryPrompt();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
initializeGoogleSignIn = () => {
|
||||
// Avoid multiple initialization attempts
|
||||
if (this.state.isInitialized || this.state.isInitializing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isInitializing: true });
|
||||
|
||||
if (!window.google || !window.google.accounts || !window.google.accounts.id) {
|
||||
console.error('Google Sign-In API not loaded yet');
|
||||
this.setState({ isInitializing: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: this.context.clientId,
|
||||
callback: this.handleCredentialResponse,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
isInitialized: true,
|
||||
isInitializing: false
|
||||
}, () => {
|
||||
// Auto-prompt immediately if autoInitiate is true
|
||||
if (this.props.autoInitiate && !this.state.promptShown && !this.state.isPrompting) {
|
||||
this.setState({ promptShown: true });
|
||||
this.schedulePrompt(100);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Google Sign-In initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Google Sign-In:', error);
|
||||
this.setState({
|
||||
isInitializing: false
|
||||
});
|
||||
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleCredentialResponse = (response) => {
|
||||
console.log('cred',response);
|
||||
const { onSuccess, onError } = this.props;
|
||||
|
||||
// @note Reset prompting state when response is received
|
||||
this.setState({ isPrompting: false });
|
||||
|
||||
if (response && response.credential) {
|
||||
// Call onSuccess with the credential
|
||||
if (onSuccess) {
|
||||
onSuccess(response);
|
||||
}
|
||||
} else {
|
||||
// Call onError if something went wrong
|
||||
if (onError) {
|
||||
onError(new Error('Failed to get credential from Google'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
// @note Prevent multiple clicks while prompting
|
||||
if (this.state.isPrompting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If not initialized yet, try initializing first
|
||||
if (!this.state.isInitialized && !this.state.isInitializing) {
|
||||
this.initializeGoogleSignIn();
|
||||
// Add a small delay before attempting to prompt
|
||||
this.schedulePrompt(300);
|
||||
return;
|
||||
}
|
||||
|
||||
this.tryPrompt();
|
||||
};
|
||||
|
||||
tryPrompt = () => {
|
||||
// @note Prevent multiple simultaneous prompts
|
||||
if (this.state.isPrompting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.google || !window.google.accounts || !window.google.accounts.id) {
|
||||
console.error('Google Sign-In API not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.setState({ isPrompting: true });
|
||||
window.google.accounts.id.prompt();
|
||||
this.setState({ promptShown: true });
|
||||
console.log('Google Sign-In prompt displayed');
|
||||
} catch (error) {
|
||||
console.error('Error prompting Google Sign-In:', error);
|
||||
this.setState({ isPrompting: false });
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { disabled, style, className, text = 'Mit Google anmelden' } = this.props;
|
||||
const { isInitializing, isPrompting } = this.state;
|
||||
const isLoading = isInitializing || isPrompting || (this.context && !this.context.isLoaded);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<GoogleIcon />}
|
||||
onClick={this.handleClick}
|
||||
disabled={disabled || isLoading}
|
||||
style={{ backgroundColor: '#4285F4', color: 'white', ...style }}
|
||||
className={className}
|
||||
>
|
||||
{isLoading ? 'Loading...' : text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default GoogleLoginButton;
|
||||
99
src/components/Header.js
Normal file
99
src/components/Header.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { Component } from 'react';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import Container from '@mui/material/Container';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// Import extracted components
|
||||
import { Logo, SearchBar, ButtonGroupWithRouter, CategoryList } from './header/index.js';
|
||||
|
||||
// Main Header Component
|
||||
class Header extends Component {
|
||||
static contextType = SocketContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
cartItems: []
|
||||
};
|
||||
}
|
||||
|
||||
handleCartQuantityChange = (productId, quantity) => {
|
||||
this.setState(prevState => ({
|
||||
cartItems: prevState.cartItems.map(item =>
|
||||
item.id === productId ? { ...item, quantity } : item
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
handleCartRemoveItem = (productId) => {
|
||||
this.setState(prevState => ({
|
||||
cartItems: prevState.cartItems.filter(item => item.id !== productId)
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
// Get socket directly from context in render method
|
||||
const socket = this.context;
|
||||
const { isHomePage, isProfilePage } = this.props;
|
||||
|
||||
return (
|
||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
|
||||
<Toolbar sx={{ minHeight: 64 }}>
|
||||
<Container maxWidth="lg" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{/* First row: Logo and ButtonGroup on xs, all items on larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexDirection: { xs: 'column', sm: 'row' }
|
||||
}}>
|
||||
{/* Top row for xs, single row for larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: { xs: 'space-between', sm: 'flex-start' }
|
||||
}}>
|
||||
<Logo />
|
||||
{/* SearchBar visible on sm and up */}
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' }, flexGrow: 1 }}>
|
||||
<SearchBar />
|
||||
</Box>
|
||||
<ButtonGroupWithRouter socket={socket}/>
|
||||
</Box>
|
||||
|
||||
{/* Second row: SearchBar only on xs */}
|
||||
<Box sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
width: '100%',
|
||||
mt: 1,mb: 1
|
||||
}}>
|
||||
<SearchBar />
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Toolbar>
|
||||
{(isHomePage || this.props.categoryId || isProfilePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId} socket={socket} />}
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Use a wrapper function to provide context
|
||||
const HeaderWithContext = (props) => {
|
||||
const location = useLocation();
|
||||
const isHomePage = location.pathname === '/';
|
||||
const isProfilePage = location.pathname === '/profile';
|
||||
|
||||
return (
|
||||
<SocketContext.Consumer>
|
||||
{socket => <Header {...props} socket={socket} isHomePage={isHomePage} isProfilePage={isProfilePage} />}
|
||||
</SocketContext.Consumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderWithContext;
|
||||
325
src/components/Images.js
Normal file
325
src/components/Images.js
Normal file
@@ -0,0 +1,325 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import LoupeIcon from '@mui/icons-material/Loupe';
|
||||
|
||||
class Images extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { mainPic:0,pics:[]};
|
||||
|
||||
console.log('Images constructor',props);
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.updatePics(0);
|
||||
}
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.fullscreenOpen !== this.props.fullscreenOpen) {
|
||||
this.updatePics();
|
||||
}
|
||||
}
|
||||
|
||||
updatePics = (newMainPic = this.state.mainPic) => {
|
||||
if (!window.tinyPicCache) window.tinyPicCache = {};
|
||||
if (!window.smallPicCache) window.smallPicCache = {};
|
||||
if (!window.mediumPicCache) window.mediumPicCache = {};
|
||||
if (!window.largePicCache) window.largePicCache = {};
|
||||
|
||||
if(this.props.pictureList && this.props.pictureList.length > 0){
|
||||
const bildIds = this.props.pictureList.split(',');
|
||||
|
||||
|
||||
const pics = [];
|
||||
const mainPicId = bildIds[newMainPic];
|
||||
|
||||
for(const bildId of bildIds){
|
||||
if(bildId == mainPicId){
|
||||
|
||||
if(window.largePicCache[bildId]){
|
||||
pics.push(window.largePicCache[bildId]);
|
||||
}else if(window.mediumPicCache[bildId]){
|
||||
pics.push(window.mediumPicCache[bildId]);
|
||||
if(this.props.fullscreenOpen) this.loadPic('large',bildId,newMainPic);
|
||||
}else if(window.smallPicCache[bildId]){
|
||||
pics.push(window.smallPicCache[bildId]);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}else if(window.tinyPicCache[bildId]){
|
||||
pics.push(bildId);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}else{
|
||||
pics.push(bildId);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}
|
||||
}else{
|
||||
if(window.tinyPicCache[bildId]){
|
||||
pics.push(window.tinyPicCache[bildId]);
|
||||
}else if(window.mediumPicCache[bildId]){
|
||||
pics.push(window.mediumPicCache[bildId]);
|
||||
this.loadPic('tiny',bildId,newMainPic);
|
||||
}else{
|
||||
pics.push(null);
|
||||
this.loadPic('tiny',bildId,pics.length-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('pics',pics);
|
||||
this.setState({ pics, mainPic: newMainPic });
|
||||
}else{
|
||||
if(this.state.pics.length > 0) this.setState({ pics:[], mainPic: newMainPic });
|
||||
}
|
||||
}
|
||||
|
||||
loadPic = (size,bildId,index) => {
|
||||
this.props.socket.emit('getPic', { bildId, size }, (res) => {
|
||||
if(res.success){
|
||||
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
|
||||
if(size === 'medium') window.mediumPicCache[bildId] = url;
|
||||
if(size === 'small') window.smallPicCache[bildId] = url;
|
||||
if(size === 'tiny') window.tinyPicCache[bildId] = url;
|
||||
if(size === 'large') window.largePicCache[bildId] = url;
|
||||
const pics = this.state.pics;
|
||||
pics[index] = url
|
||||
this.setState({ pics });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleThumbnailClick = (clickedPic) => {
|
||||
// Find the original index of the clicked picture in the full pics array
|
||||
const originalIndex = this.state.pics.findIndex(pic => pic === clickedPic);
|
||||
if (originalIndex !== -1) {
|
||||
this.updatePics(originalIndex);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
{this.state.pics[this.state.mainPic] && (
|
||||
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="400"
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)'
|
||||
}
|
||||
}}
|
||||
image={this.state.pics[this.state.mainPic]}
|
||||
onClick={this.props.onOpenFullscreen}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
disableRipple
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
pointerEvents: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LoupeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1,mb: 1 }}>
|
||||
{this.state.pics.filter(pic => pic !== null && pic !== this.state.pics[this.state.mainPic]).map((pic, filterIndex) => {
|
||||
// Find the original index in the full pics array
|
||||
const originalIndex = this.state.pics.findIndex(p => p === pic);
|
||||
return (
|
||||
<Box key={filterIndex} sx={{ position: 'relative' }}>
|
||||
<Badge
|
||||
badgeContent={originalIndex + 1}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
backgroundColor: 'rgba(119, 155, 191, 0.79)',
|
||||
color: 'white',
|
||||
fontSize: '0.7rem',
|
||||
minWidth: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
top: 4,
|
||||
right: 4,
|
||||
border: '2px solid rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: 'bold',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out'
|
||||
},
|
||||
'&:hover .MuiBadge-badge': {
|
||||
opacity: 1
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="80"
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
border: '2px solid transparent',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
border: '2px solid #1976d2',
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.2)'
|
||||
}
|
||||
}}
|
||||
image={pic}
|
||||
onClick={() => this.handleThumbnailClick(pic)}
|
||||
/>
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{/* Fullscreen Dialog */}
|
||||
<Dialog
|
||||
open={this.props.fullscreenOpen || false}
|
||||
onClose={this.props.onCloseFullscreen}
|
||||
maxWidth={false}
|
||||
fullScreen
|
||||
sx={{
|
||||
'& .MuiDialog-paper': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
sx={{
|
||||
p: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
height: '100vh',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Only close if clicking on the background (DialogContent itself)
|
||||
if (e.target === e.currentTarget) {
|
||||
this.props.onCloseFullscreen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<IconButton
|
||||
onClick={this.props.onCloseFullscreen}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
|
||||
{/* Main Image in Fullscreen */}
|
||||
{this.state.pics[this.state.mainPic] && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
width: '90vw',
|
||||
height: '80vh'
|
||||
}}
|
||||
image={this.state.pics[this.state.mainPic]}
|
||||
onClick={this.props.onCloseFullscreen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Stack in Fullscreen */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: '90%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Stack direction="row" spacing={2} sx={{ justifyContent: 'center', p: 3 }}>
|
||||
{this.state.pics.filter(pic => pic !== null && pic !== this.state.pics[this.state.mainPic]).map((pic, filterIndex) => {
|
||||
// Find the original index in the full pics array
|
||||
const originalIndex = this.state.pics.findIndex(p => p === pic);
|
||||
return (
|
||||
<Box key={filterIndex} sx={{ position: 'relative' }}>
|
||||
<Badge
|
||||
badgeContent={originalIndex + 1}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
backgroundColor: 'rgba(119, 155, 191, 0.79)',
|
||||
color: 'white',
|
||||
fontSize: '0.7rem',
|
||||
minWidth: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
top: 4,
|
||||
right: 4,
|
||||
border: '2px solid rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: 'bold',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out'
|
||||
},
|
||||
'&:hover .MuiBadge-badge': {
|
||||
opacity: 1
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="60"
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
border: '2px solid #1976d2',
|
||||
transform: 'scale(1.1)',
|
||||
boxShadow: '0 4px 8px rgba(25, 118, 210, 0.5)'
|
||||
}
|
||||
}}
|
||||
image={pic}
|
||||
onClick={() => this.handleThumbnailClick(pic)}
|
||||
/>
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Images;
|
||||
743
src/components/LoginComponent.js
Normal file
743
src/components/LoginComponent.js
Normal file
@@ -0,0 +1,743 @@
|
||||
import React, { lazy, Component, Suspense } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import { withRouter } from './withRouter.js';
|
||||
import GoogleLoginButton from './GoogleLoginButton.js';
|
||||
import CartSyncDialog from './CartSyncDialog.js';
|
||||
import { localAndArchiveServer, mergeCarts } from '../utils/cartUtils.js';
|
||||
import config from '../config.js';
|
||||
|
||||
// Lazy load GoogleAuthProvider
|
||||
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
|
||||
|
||||
// Function to check if user is logged in
|
||||
export const isUserLoggedIn = () => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(storedUser);
|
||||
console.log('Parsed User:', parsedUser);
|
||||
return { isLoggedIn: true, user: parsedUser, isAdmin: !!parsedUser.admin };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error parsing user from sessionStorage:', error);
|
||||
sessionStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
console.log('isUserLoggedIn', false);
|
||||
return { isLoggedIn: false, user: null, isAdmin: false };
|
||||
};
|
||||
|
||||
// Hilfsfunktion zum Vergleich zweier Cart-Arrays
|
||||
function cartsAreIdentical(cartA, cartB) {
|
||||
console.log('Vergleiche Carts:', {cartA, cartB});
|
||||
if (!Array.isArray(cartA) || !Array.isArray(cartB)) {
|
||||
console.log('Mindestens eines der Carts ist kein Array');
|
||||
return false;
|
||||
}
|
||||
if (cartA.length !== cartB.length) {
|
||||
console.log('Unterschiedliche Längen:', cartA.length, cartB.length);
|
||||
return false;
|
||||
}
|
||||
const sortById = arr => [...arr].sort((a, b) => (a.id > b.id ? 1 : -1));
|
||||
const aSorted = sortById(cartA);
|
||||
const bSorted = sortById(cartB);
|
||||
for (let i = 0; i < aSorted.length; i++) {
|
||||
if (aSorted[i].id !== bSorted[i].id) {
|
||||
console.log('Unterschiedliche IDs:', aSorted[i].id, bSorted[i].id, aSorted[i], bSorted[i]);
|
||||
return false;
|
||||
}
|
||||
if (aSorted[i].quantity !== bSorted[i].quantity) {
|
||||
console.log('Unterschiedliche Mengen:', aSorted[i].id, aSorted[i].quantity, bSorted[i].quantity);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
console.log('Carts sind identisch');
|
||||
return true;
|
||||
}
|
||||
|
||||
export class LoginComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
open: false,
|
||||
tabValue: 0,
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
error: '',
|
||||
loading: false,
|
||||
success: '',
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
user: null,
|
||||
anchorEl: null,
|
||||
showGoogleAuth: false,
|
||||
cartSyncOpen: false,
|
||||
localCartSync: [],
|
||||
serverCartSync: [],
|
||||
pendingNavigate: null,
|
||||
privacyConfirmed: sessionStorage.getItem('privacyConfirmed') === 'true'
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Make the open function available globally
|
||||
window.openLoginDrawer = this.handleOpen;
|
||||
|
||||
// Check if user is logged in
|
||||
const { isLoggedIn: userIsLoggedIn, user: storedUser } = isUserLoggedIn();
|
||||
if (userIsLoggedIn) {
|
||||
this.setState({
|
||||
user: storedUser,
|
||||
isAdmin: !!storedUser.admin,
|
||||
isLoggedIn: true
|
||||
});
|
||||
}
|
||||
|
||||
if (this.props.open) {
|
||||
this.setState({ open: true });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.open !== prevProps.open) {
|
||||
this.setState({ open: this.props.open });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup function to remove global reference when component unmounts
|
||||
window.openLoginDrawer = undefined;
|
||||
}
|
||||
|
||||
resetForm = () => {
|
||||
this.setState({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
error: '',
|
||||
success: '',
|
||||
loading: false,
|
||||
showGoogleAuth: false // Reset Google auth state when form is reset
|
||||
});
|
||||
};
|
||||
|
||||
handleOpen = () => {
|
||||
this.setState({
|
||||
open: true,
|
||||
loading: false,
|
||||
privacyConfirmed: sessionStorage.getItem('privacyConfirmed') === 'true'
|
||||
});
|
||||
this.resetForm();
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({
|
||||
open: false,
|
||||
showGoogleAuth: false // Reset Google auth state when dialog closes
|
||||
});
|
||||
this.resetForm();
|
||||
};
|
||||
|
||||
handleTabChange = (event, newValue) => {
|
||||
this.setState({
|
||||
tabValue: newValue,
|
||||
error: '',
|
||||
success: ''
|
||||
});
|
||||
};
|
||||
|
||||
validateEmail = (email) => {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
};
|
||||
|
||||
handleLogin = () => {
|
||||
const { email, password } = this.state;
|
||||
const { socket, location, navigate } = this.props;
|
||||
|
||||
if (!email || !password) {
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call verifyUser socket endpoint
|
||||
if (!socket || !socket.connected) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('verifyUser', { email, password }, (response) => {
|
||||
console.log('LoginComponent: verifyUser', response);
|
||||
if (response.success) {
|
||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||
this.setState({
|
||||
user: response.user,
|
||||
isLoggedIn: true,
|
||||
isAdmin: !!response.user.admin
|
||||
});
|
||||
|
||||
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
const dispatchLoginEvent = () => {
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
navigate(redirectTo);
|
||||
}
|
||||
|
||||
try {
|
||||
const newCart = JSON.parse(response.user.cart);
|
||||
const localCartArr = window.cart ? Object.values(window.cart) : [];
|
||||
const serverCartArr = newCart ? Object.values(newCart) : [];
|
||||
|
||||
if (serverCartArr.length === 0) {
|
||||
if (socket && socket.connected) {
|
||||
socket.emit('updateCart', window.cart);
|
||||
}
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
||||
window.cart = serverCartArr;
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (cartsAreIdentical(localCartArr, serverCartArr)) {
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else {
|
||||
this.setState({
|
||||
cartSyncOpen: true,
|
||||
localCartSync: localCartArr,
|
||||
serverCartSync: serverCartArr,
|
||||
pendingNavigate: dispatchLoginEvent
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing cart:', response.user, error);
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Anmeldung fehlgeschlagen'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleRegister = () => {
|
||||
const { email, password, confirmPassword } = this.state;
|
||||
const { socket } = this.props;
|
||||
|
||||
if (!email || !password || !confirmPassword) {
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
this.setState({ error: 'Passwörter stimmen nicht überein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
this.setState({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call createUser socket endpoint
|
||||
if (!socket || !socket.connected) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('createUser', { email, password }, (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
|
||||
tabValue: 0 // Switch to login tab
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Registrierung fehlgeschlagen'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleUserMenuClick = (event) => {
|
||||
this.setState({ anchorEl: event.currentTarget });
|
||||
};
|
||||
|
||||
handleUserMenuClose = () => {
|
||||
this.setState({ anchorEl: null });
|
||||
};
|
||||
|
||||
handleLogout = () => {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// If socket is not connected, just clear local storage
|
||||
sessionStorage.removeItem('user');
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
window.dispatchEvent(new CustomEvent('userLoggedOut'));
|
||||
this.setState({
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
isAdmin: false,
|
||||
anchorEl: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit('logout', (response) => {
|
||||
if(response.success){
|
||||
sessionStorage.removeItem('user');
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
this.props.navigate('/');
|
||||
this.setState({
|
||||
user: null,
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
anchorEl: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleForgotPassword = () => {
|
||||
const { email } = this.state;
|
||||
const { socket } = this.props;
|
||||
|
||||
if (!email) {
|
||||
this.setState({ error: 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call resetPassword socket endpoint
|
||||
socket.emit('resetPassword', {
|
||||
email,
|
||||
domain: window.location.origin
|
||||
}, (response) => {
|
||||
console.log('Reset Password Response:', response);
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Fehler beim Senden der E-Mail'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Google login functionality
|
||||
handleGoogleLoginSuccess = (credentialResponse) => {
|
||||
const { socket, location, navigate } = this.props;
|
||||
this.setState({ loading: true, error: '' });
|
||||
console.log('beforeG',credentialResponse)
|
||||
|
||||
|
||||
|
||||
socket.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
|
||||
console.log('google respo',response);
|
||||
if (response.success) {
|
||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||
this.setState({
|
||||
isLoggedIn: true,
|
||||
isAdmin: !!response.user.admin,
|
||||
user: response.user
|
||||
});
|
||||
|
||||
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
const dispatchLoginEvent = () => {
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
navigate(redirectTo);
|
||||
};
|
||||
|
||||
try {
|
||||
const newCart = JSON.parse(response.user.cart);
|
||||
const localCartArr = window.cart ? Object.values(window.cart) : [];
|
||||
const serverCartArr = newCart ? Object.values(newCart) : [];
|
||||
|
||||
if (serverCartArr.length === 0) {
|
||||
socket.emit('updateCart', window.cart);
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
||||
window.cart = serverCartArr;
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (cartsAreIdentical(localCartArr, serverCartArr)) {
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else {
|
||||
this.setState({
|
||||
cartSyncOpen: true,
|
||||
localCartSync: localCartArr,
|
||||
serverCartSync: serverCartArr,
|
||||
pendingNavigate: dispatchLoginEvent
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing cart:', response.user, error);
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false // Reset Google auth state on failed login
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleGoogleLoginError = (error) => {
|
||||
console.error('Google Login Error:', error);
|
||||
this.setState({
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false, // Reset Google auth state on error
|
||||
loading: false
|
||||
});
|
||||
};
|
||||
|
||||
handleCartSyncConfirm = async (option) => {
|
||||
const { localCartSync, serverCartSync, pendingNavigate } = this.state;
|
||||
switch (option) {
|
||||
case 'useLocalArchive':
|
||||
localAndArchiveServer(localCartSync, serverCartSync);
|
||||
break;
|
||||
case 'deleteServer':
|
||||
this.props.socket.emit('updateCart', window.cart)
|
||||
break;
|
||||
case 'useServer':
|
||||
window.cart = serverCartSync;
|
||||
break;
|
||||
case 'merge':
|
||||
default: {
|
||||
const merged = mergeCarts(localCartSync, serverCartSync);
|
||||
console.log('MERGED CART RESULT:', merged);
|
||||
window.cart = merged;
|
||||
break;
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
this.setState({ cartSyncOpen: false, localCartSync: [], serverCartSync: [], pendingNavigate: null });
|
||||
this.handleClose();
|
||||
if (pendingNavigate) pendingNavigate();
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
open,
|
||||
tabValue,
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
error,
|
||||
loading,
|
||||
success,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
anchorEl,
|
||||
showGoogleAuth,
|
||||
cartSyncOpen,
|
||||
localCartSync,
|
||||
serverCartSync,
|
||||
privacyConfirmed
|
||||
} = this.state;
|
||||
|
||||
const { open: openProp, handleClose: handleCloseProp } = this.props;
|
||||
const isExternallyControlled = openProp !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isExternallyControlled && (
|
||||
isLoggedIn ? (
|
||||
<>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={this.handleUserMenuClick}
|
||||
startIcon={<PersonIcon />}
|
||||
color={isAdmin ? 'secondary' : 'inherit'}
|
||||
sx={{ my: 1, mx: 1.5 }}
|
||||
>
|
||||
Profil
|
||||
</Button>
|
||||
<Menu
|
||||
disableScrollLock={true}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={this.handleUserMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<MenuItem component={Link} to="/profile" onClick={this.handleUserMenuClose}>Profil</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#cart" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellabschluss</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#orders" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellungen</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#settings" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Einstellungen</MenuItem>
|
||||
<Divider />
|
||||
{isAdmin ? <MenuItem component={Link} to="/admin" onClick={this.handleUserMenuClose}>Admin Dashboard</MenuItem> : null}
|
||||
{isAdmin ? <MenuItem component={Link} to="/admin/users" onClick={this.handleUserMenuClose}>Admin Users</MenuItem> : null}
|
||||
<MenuItem onClick={this.handleLogout}>Abmelden</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={this.handleOpen}
|
||||
sx={{ my: 1, mx: 1.5 }}
|
||||
>
|
||||
Anmelden
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleCloseProp || this.handleClose}
|
||||
disableScrollLock
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
>
|
||||
<DialogTitle sx={{ bgcolor: 'white', pb: 0 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h6" color="#2e7d32" fontWeight="bold">
|
||||
{tabValue === 0 ? 'Anmelden' : 'Registrieren'}
|
||||
</Typography>
|
||||
<IconButton edge="end" onClick={this.handleClose} aria-label="close">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={this.handleTabChange}
|
||||
variant="fullWidth"
|
||||
sx={{ mb: 2 }}
|
||||
TabIndicatorProps={{
|
||||
style: { backgroundColor: '#2e7d32' }
|
||||
}}
|
||||
textColor="inherit"
|
||||
>
|
||||
<Tab
|
||||
label="ANMELDEN"
|
||||
sx={{
|
||||
color: tabValue === 0 ? '#2e7d32' : 'inherit',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="REGISTRIEREN"
|
||||
sx={{
|
||||
color: tabValue === 1 ? '#2e7d32' : 'inherit',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
|
||||
{/* Google Sign In Button */}
|
||||
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', mb: 2 }}>
|
||||
{!privacyConfirmed && (
|
||||
<Typography variant="caption" sx={{ mb: 1, textAlign: 'center' }}>
|
||||
Mit dem Click auf "Mit Google anmelden" akzeptiere ich die <Link to="/datenschutz" style={{ color: '#4285F4' }}>Datenschutzbestimmungen</Link>
|
||||
</Typography>
|
||||
)}
|
||||
{!showGoogleAuth && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonIcon />}
|
||||
onClick={() => {
|
||||
sessionStorage.setItem('privacyConfirmed', 'true');
|
||||
this.setState({ showGoogleAuth: true, privacyConfirmed: true });
|
||||
}}
|
||||
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
|
||||
>
|
||||
Mit Google anmelden
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showGoogleAuth && (
|
||||
<Suspense fallback={
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonIcon />}
|
||||
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
|
||||
>
|
||||
Mit Google anmelden
|
||||
</Button>
|
||||
}>
|
||||
<GoogleAuthProvider clientId={config.googleClientId}>
|
||||
<GoogleLoginButton
|
||||
onSuccess={this.handleGoogleLoginSuccess}
|
||||
onError={this.handleGoogleLoginError}
|
||||
text="Mit Google anmelden"
|
||||
style={{ width: '100%', backgroundColor: '#4285F4' }}
|
||||
autoInitiate={true}
|
||||
/>
|
||||
</GoogleAuthProvider>
|
||||
</Suspense>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
|
||||
{/* OR Divider */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', my: 2 }}>
|
||||
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
|
||||
<Typography variant="body2" sx={{ px: 2, color: '#757575' }}>ODER</Typography>
|
||||
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
|
||||
|
||||
|
||||
<Box sx={{ py: 1 }}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={email}
|
||||
onChange={(e) => this.setState({ email: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={password}
|
||||
onChange={(e) => this.setState({ password: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{tabValue === 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1, mb: 1 }}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={this.handleForgotPassword}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
textTransform: 'none',
|
||||
'&:hover': { backgroundColor: 'transparent', textDecoration: 'underline' }
|
||||
}}
|
||||
>
|
||||
Passwort vergessen?
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tabValue === 1 && (
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Passwort bestätigen"
|
||||
type="password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => this.setState({ confirmPassword: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" mt={2}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={tabValue === 0 ? this.handleLogin : this.handleRegister}
|
||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||
>
|
||||
{tabValue === 0 ? 'ANMELDEN' : 'REGISTRIEREN'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CartSyncDialog
|
||||
open={cartSyncOpen}
|
||||
localCart={localCartSync}
|
||||
serverCart={serverCartSync}
|
||||
onClose={() => {
|
||||
const { pendingNavigate } = this.state;
|
||||
this.setState({ cartSyncOpen: false, pendingNavigate: null });
|
||||
this.handleClose();
|
||||
if (pendingNavigate) pendingNavigate();
|
||||
}}
|
||||
onConfirm={this.handleCartSyncConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(LoginComponent);
|
||||
365
src/components/Product.js
Normal file
365
src/components/Product.js
Normal file
@@ -0,0 +1,365 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddToCartButton from './AddToCartButton.js';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
|
||||
class Product extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._isMounted = false;
|
||||
|
||||
if (!window.smallPicCache) {
|
||||
window.smallPicCache = {};
|
||||
}
|
||||
|
||||
if(this.props.pictureList && this.props.pictureList.length > 0 && this.props.pictureList.split(',').length > 0) {
|
||||
const bildId = this.props.pictureList.split(',')[0];
|
||||
if(window.smallPicCache[bildId]){
|
||||
this.state = {image:window.smallPicCache[bildId],loading:false, error: false}
|
||||
}else{
|
||||
this.state = {image: null, loading: true, error: false};
|
||||
this.props.socket.emit('getPic', { bildId, size:'small' }, (res) => {
|
||||
if(res.success){
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
if (this._isMounted) {
|
||||
this.setState({image: window.smallPicCache[bildId], loading: false});
|
||||
} else {
|
||||
this.state.image = window.smallPicCache[bildId];
|
||||
this.state.loading = false;
|
||||
}
|
||||
}else{
|
||||
console.log('Fehler beim Laden des Bildes:', res);
|
||||
if (this._isMounted) {
|
||||
this.setState({error: true, loading: false});
|
||||
} else {
|
||||
this.state.error = true;
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}else{
|
||||
this.state = {image: null, loading: false, error: false};
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
}
|
||||
|
||||
handleQuantityChange = (quantity) => {
|
||||
console.log(`Product: ${this.props.name}, Quantity: ${quantity}`);
|
||||
// In a real app, this would update a cart state in a parent component or Redux store
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
id, name, price, available, manufacturer, seoName,
|
||||
currency, vat, massMenge, massEinheit, thc,
|
||||
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier
|
||||
} = this.props;
|
||||
|
||||
const isNew = neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
const showThcBadge = thc > 0;
|
||||
let thcBadgeColor = '#4caf50'; // Green default
|
||||
if (thc > 30) {
|
||||
thcBadgeColor = '#f44336'; // Red for > 30
|
||||
} else if (thc > 25) {
|
||||
thcBadgeColor = '#ffeb3b'; // Yellow for > 25
|
||||
}
|
||||
const showFloweringWeeksBadge = floweringWeeks > 0;
|
||||
let floweringWeeksBadgeColor = '#4caf50'; // Green default
|
||||
if (floweringWeeks > 12) {
|
||||
floweringWeeksBadgeColor = '#f44336'; // Red for > 12
|
||||
} else if (floweringWeeks > 8) {
|
||||
floweringWeeksBadgeColor = '#ffeb3b'; // Yellow for > 8
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
width: { xs: '100%', sm: 'auto' }
|
||||
}}>
|
||||
{isNew && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-15px',
|
||||
right: '-15px',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
zIndex: 999,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
{/* Background star - slightly larger and rotated */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="56"
|
||||
height="56"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-3px',
|
||||
left: '-3px',
|
||||
transform: 'rotate(20deg)'
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#20403a"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Middle star - medium size with different rotation */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="53"
|
||||
height="53"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-1.5px',
|
||||
left: '-1.5px',
|
||||
transform: 'rotate(-25deg)'
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#40736b"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Foreground star - main star with text */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="50"
|
||||
height="50"
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#609688"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Text as a separate element to position it at the top */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '45%',
|
||||
left: '45%',
|
||||
transform: 'translate(-50%, -50%) rotate(-10deg)',
|
||||
color: 'white',
|
||||
fontWeight: '900',
|
||||
fontSize: '16px',
|
||||
textShadow: '0px 1px 2px rgba(0,0,0,0.5)',
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
NEU
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
sx={{
|
||||
width: { xs: 'calc(100vw - 48px)', sm: '250px' },
|
||||
minWidth: { xs: 'calc(100vw - 48px)', sm: '250px' },
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '8px',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0px 10px 20px rgba(0,0,0,0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showThcBadge && (
|
||||
<div aria-label={`THC Anteil: ${thc}%`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
backgroundColor: thcBadgeColor,
|
||||
color: thc > 25 && thc <= 30 ? '#000000' : '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
padding: '2px 0',
|
||||
width: '80px',
|
||||
textAlign: 'center',
|
||||
zIndex: 999,
|
||||
fontSize: '9px',
|
||||
boxShadow: '0px 2px 4px rgba(0,0,0,0.2)',
|
||||
transform: 'rotate(-45deg) translateX(-40px) translateY(15px)',
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
THC {thc}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFloweringWeeksBadge && (
|
||||
<div aria-label={`Flowering Weeks: ${floweringWeeks}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
backgroundColor: floweringWeeksBadgeColor,
|
||||
color: floweringWeeks > 8 && floweringWeeks <= 12 ? '#000000' : '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
padding: '1px 0',
|
||||
width: '100px',
|
||||
textAlign: 'center',
|
||||
zIndex: 999,
|
||||
fontSize: '9px',
|
||||
boxShadow: '0px 2px 4px rgba(0,0,0,0.2)',
|
||||
transform: 'rotate(-45deg) translateX(-50px) translateY(32px)',
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
{floweringWeeks} Wochen
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/Artikel/${seoName}`}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit'
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
position: 'relative',
|
||||
height: { xs: '240px', sm: '180px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px'
|
||||
}}>
|
||||
{this.state.loading ? (
|
||||
<CircularProgress sx={{ color: '#90ffc0' }} />
|
||||
|
||||
) : this.state.image === null ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
||||
image="/assets/images/nopicture.jpg"
|
||||
alt={name}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
||||
image={this.state.image}
|
||||
alt={name}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<CardContent sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'&.MuiCardContent-root:last-child': {
|
||||
paddingBottom: 0
|
||||
}
|
||||
}}>
|
||||
<Typography
|
||||
gutterBottom
|
||||
variant="h6"
|
||||
component="h2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minHeight: '3.4em'
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" style={{minHeight:'1.5em'}}>
|
||||
{manufacturer || ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<div style={{padding:'0px',margin:'0px',minHeight:'3.8em'}}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="primary"
|
||||
sx={{ mt: 'auto', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
|
||||
>
|
||||
<span>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
|
||||
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>(incl. {vat}% USt.,*)</small>
|
||||
|
||||
|
||||
|
||||
</Typography>
|
||||
{massMenge != 1 && massEinheit && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
|
||||
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price/massMenge)}/{massEinheit})
|
||||
</Typography> )}
|
||||
</div>
|
||||
{/*incoming*/}
|
||||
</CardContent>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2, pt: 0, display: 'flex', alignItems: 'center' }}>
|
||||
<IconButton
|
||||
component={Link}
|
||||
to={`/Artikel/${seoName}`}
|
||||
size="small"
|
||||
sx={{ mr: 1, color: 'text.secondary' }}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</IconButton>
|
||||
<AddToCartButton cartButton={true} availableSupplier={availableSupplier} available={available} incoming={incoming} seoName={seoName} pictureList={this.props.pictureList} id={id} price={price} vat={vat} weight={weight} name={name} versandklasse={versandklasse}/>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Product;
|
||||
572
src/components/ProductDetailPage.js
Normal file
572
src/components/ProductDetailPage.js
Normal file
@@ -0,0 +1,572 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Typography, CardMedia, Stack, Chip } from "@mui/material";
|
||||
import { Link } from "react-router-dom";
|
||||
import parse from "html-react-parser";
|
||||
import AddToCartButton from "./AddToCartButton.js";
|
||||
import Images from "./Images.js";
|
||||
|
||||
// Utility function to clean product names by removing trailing number in parentheses
|
||||
const cleanProductName = (name) => {
|
||||
if (!name) return "";
|
||||
// Remove patterns like " (1)", " (3)", " (10)" at the end of the string
|
||||
return name.replace(/\s*\(\d+\)\s*$/, "").trim();
|
||||
};
|
||||
|
||||
// Product detail page with image loading
|
||||
class ProductDetailPage extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
if (
|
||||
window.productDetailCache &&
|
||||
window.productDetailCache[this.props.seoName]
|
||||
) {
|
||||
this.state = {
|
||||
product: window.productDetailCache[this.props.seoName],
|
||||
loading: false,
|
||||
error: null,
|
||||
attributeImages: {},
|
||||
attributes: [],
|
||||
isSteckling: false,
|
||||
imageDialogOpen: false,
|
||||
};
|
||||
} else {
|
||||
this.state = {
|
||||
product: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
attributeImages: {},
|
||||
attributes: [],
|
||||
isSteckling: false,
|
||||
imageDialogOpen: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadProductData();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.seoName !== this.props.seoName)
|
||||
this.setState(
|
||||
{ product: null, loading: true, error: null, imageDialogOpen: false },
|
||||
this.loadProductData
|
||||
);
|
||||
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected && this.state.loading) {
|
||||
// Socket just connected and we're still loading, retry loading data
|
||||
this.loadProductData();
|
||||
}
|
||||
}
|
||||
|
||||
loadProductData = () => {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to load product data");
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit(
|
||||
"getProductView",
|
||||
{ seoName: this.props.seoName },
|
||||
(res) => {
|
||||
if (res.success) {
|
||||
res.product.seoName = this.props.seoName;
|
||||
this.setState({
|
||||
product: res.product,
|
||||
loading: false,
|
||||
error: null,
|
||||
imageDialogOpen: false,
|
||||
attributes: res.attributes
|
||||
});
|
||||
console.log("getProductView", res);
|
||||
|
||||
// Initialize window-level attribute image cache if it doesn't exist
|
||||
if (!window.attributeImageCache) {
|
||||
window.attributeImageCache = {};
|
||||
}
|
||||
|
||||
if (res.attributes && res.attributes.length > 0) {
|
||||
const attributeImages = {};
|
||||
|
||||
for (const attribute of res.attributes) {
|
||||
const cacheKey = attribute.kMerkmalWert;
|
||||
|
||||
if (attribute.cName == "Anzahl")
|
||||
this.setState({ isSteckling: true });
|
||||
|
||||
// Check if we have a cached result (either URL or negative result)
|
||||
if (window.attributeImageCache[cacheKey]) {
|
||||
const cached = window.attributeImageCache[cacheKey];
|
||||
if (cached.url) {
|
||||
// Use cached URL
|
||||
attributeImages[cacheKey] = cached.url;
|
||||
}
|
||||
} else {
|
||||
// Not in cache, fetch from server
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit(
|
||||
"getAttributePicture",
|
||||
{ id: cacheKey },
|
||||
(res) => {
|
||||
console.log("getAttributePicture", res);
|
||||
if (res.success && !res.noPicture) {
|
||||
const blob = new Blob([res.imageBuffer], {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Cache the successful URL
|
||||
window.attributeImageCache[cacheKey] = {
|
||||
url: url,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Update state and force re-render
|
||||
this.setState(prevState => ({
|
||||
attributeImages: {
|
||||
...prevState.attributeImages,
|
||||
[cacheKey]: url
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
// Cache negative result to avoid future requests
|
||||
// This handles both failure cases and success with noPicture: true
|
||||
window.attributeImageCache[cacheKey] = {
|
||||
noImage: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial state with cached images
|
||||
if (Object.keys(attributeImages).length > 0) {
|
||||
this.setState({ attributeImages });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"Error loading product:",
|
||||
res.error || "Unknown error",
|
||||
res
|
||||
);
|
||||
this.setState({
|
||||
product: null,
|
||||
loading: false,
|
||||
error: "Error loading product",
|
||||
imageDialogOpen: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleOpenDialog = () => {
|
||||
this.setState({ imageDialogOpen: true });
|
||||
};
|
||||
|
||||
handleCloseDialog = () => {
|
||||
this.setState({ imageDialogOpen: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { product, loading, error, attributeImages, isSteckling, attributes } =
|
||||
this.state;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Produkt wird geladen...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ p: 4, textAlign: "center" }}>
|
||||
<Typography variant="h5" gutterBottom color="error">
|
||||
Fehler
|
||||
</Typography>
|
||||
<Typography>{error}</Typography>
|
||||
<Link to="/" style={{ textDecoration: "none" }}>
|
||||
<Typography color="primary" sx={{ mt: 2 }}>
|
||||
Zurück zur Startseite
|
||||
</Typography>
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<Box sx={{ p: 4, textAlign: "center" }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Produkt nicht gefunden
|
||||
</Typography>
|
||||
<Typography>
|
||||
Das gesuchte Produkt existiert nicht oder wurde entfernt.
|
||||
</Typography>
|
||||
<Link to="/" style={{ textDecoration: "none" }}>
|
||||
<Typography color="primary" sx={{ mt: 2 }}>
|
||||
Zurück zur Startseite
|
||||
</Typography>
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// Format price with tax
|
||||
const priceWithTax = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(product.price);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: { xs: 2, md: 2 },
|
||||
pb: { xs: 4, md: 8 },
|
||||
maxWidth: "1400px",
|
||||
mx: "auto",
|
||||
}}
|
||||
>
|
||||
{/* Breadcrumbs */}
|
||||
<Box
|
||||
sx={{
|
||||
mb: 2,
|
||||
position: ["-webkit-sticky", "sticky"], // Provide both prefixed and standard
|
||||
top: {
|
||||
xs: "80px",
|
||||
sm: "80px",
|
||||
md: "80px",
|
||||
lg: "80px",
|
||||
} /* Offset to sit below the header 120 mith menu for md and lg*/,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
zIndex: (theme) =>
|
||||
theme.zIndex.appBar - 1 /* Just below the AppBar */,
|
||||
py: 0,
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
ml: { xs: 0, md: 0 },
|
||||
display: "inline-flex",
|
||||
px: 0,
|
||||
py: 1,
|
||||
backgroundColor: "#2e7d32", //primary dark green
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => this.props.navigate(-1)}
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
textDecoration: "none",
|
||||
color: "#fff",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", md: "row" },
|
||||
gap: 4,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: { xs: "100%", sm: "555px" },
|
||||
maxWidth: "100%",
|
||||
minHeight: "400px",
|
||||
background: "#f8f8f8",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{!product.pictureList && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="400"
|
||||
image="/assets/images/nopicture.jpg"
|
||||
alt={product.name}
|
||||
sx={{ objectFit: "cover" }}
|
||||
/>
|
||||
)}
|
||||
{product.pictureList && (
|
||||
<Images
|
||||
socket={this.props.socket}
|
||||
pictureList={product.pictureList}
|
||||
fullscreenOpen={this.state.imageDialogOpen}
|
||||
onOpenFullscreen={this.handleOpenDialog}
|
||||
onCloseFullscreen={this.handleCloseDialog}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Product Details */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: "1 1 60%",
|
||||
p: { xs: 2, md: 4 },
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Product identifiers */}
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Artikelnummer: {product.articleNumber} {product.gtin ? ` | GTIN: ${product.gtin}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Product title */}
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 600, color: "#333" }}
|
||||
>
|
||||
{cleanProductName(product.name)}
|
||||
</Typography>
|
||||
|
||||
{/* Manufacturer if available */}
|
||||
{product.manufacturer && (
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontStyle: "italic" }}>
|
||||
Hersteller: {product.manufacturer}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Attribute images and chips */}
|
||||
{(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && (
|
||||
<Stack direction="row" spacing={2} sx={{ flexWrap: "wrap", gap: 1, mb: 2 }}>
|
||||
{attributes
|
||||
.filter(attribute => attributeImages[attribute.kMerkmalWert])
|
||||
.map((attribute) => {
|
||||
const key = attribute.kMerkmalWert;
|
||||
return (
|
||||
<Box key={key} sx={{ mb: 1 }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={attributeImages[key]}
|
||||
alt={`Attribute ${key}`}
|
||||
sx={{
|
||||
maxWidth: "100px",
|
||||
maxHeight: "100px",
|
||||
objectFit: "contain",
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{attributes
|
||||
.filter(attribute => !attributeImages[attribute.kMerkmalWert])
|
||||
.map((attribute) => (
|
||||
<Chip
|
||||
key={attribute.kMerkmalWert}
|
||||
label={attribute.cWert}
|
||||
disabled
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Weight */}
|
||||
{product.weight > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Gewicht: {product.weight.toFixed(1).replace(".", ",")} kg
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Price and availability section */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: "auto",
|
||||
p: 3,
|
||||
background: "#f9f9f9",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
justifyContent: "space-between",
|
||||
alignItems: { xs: "flex-start", sm: "flex-start" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h4"
|
||||
color="primary"
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{priceWithTax}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
inkl. {product.vat}% MwSt.
|
||||
</Typography>
|
||||
{product.versandklasse &&
|
||||
product.versandklasse != "standard" &&
|
||||
product.versandklasse != "kostenlos" && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{product.versandklasse}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
gap: 2,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{isSteckling && product.available == 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<AddToCartButton
|
||||
steckling={true}
|
||||
cartButton={true}
|
||||
seoName={product.seoName}
|
||||
pictureList={product.pictureList}
|
||||
available={product.available}
|
||||
id={product.id + "steckling"}
|
||||
price={0}
|
||||
vat={product.vat}
|
||||
weight={product.weight}
|
||||
availableSupplier={product.availableSupplier}
|
||||
name={cleanProductName(product.name) + " Stecklingsvorbestellung 1 Stück"}
|
||||
versandklasse={"nur Abholung"}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
mt: 1
|
||||
}}
|
||||
>
|
||||
Abholpreis: 19,90 € pro Steckling.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<AddToCartButton
|
||||
cartButton={true}
|
||||
seoName={product.seoName}
|
||||
pictureList={product.pictureList}
|
||||
available={product.available}
|
||||
id={product.id}
|
||||
availableSupplier={product.availableSupplier}
|
||||
price={product.price}
|
||||
vat={product.vat}
|
||||
weight={product.weight}
|
||||
name={cleanProductName(product.name)}
|
||||
versandklasse={product.versandklasse}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
mt: 1
|
||||
}}
|
||||
>
|
||||
{product.id.toString().endsWith("steckling") ? "Lieferzeit: 14 Tage" :
|
||||
product.available == 1 ? "Lieferzeit: 2-3 Tage" :
|
||||
product.availableSupplier == 1 ? "Lieferzeit: 7-9 Tage" : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Product full description */}
|
||||
{product.description && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 4,
|
||||
p: 4,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
lineHeight: 1.7,
|
||||
"& p": { mt: 0, mb: 2 },
|
||||
"& strong": { fontWeight: 600 },
|
||||
}}
|
||||
>
|
||||
{parse(product.description)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductDetailPage;
|
||||
19
src/components/ProductDetailWithSocket.js
Normal file
19
src/components/ProductDetailWithSocket.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
import ProductDetailPage from './ProductDetailPage.js';
|
||||
|
||||
// Wrapper component for individual product detail page with socket
|
||||
const ProductDetailWithSocket = () => {
|
||||
const { seoName } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<SocketContext.Consumer>
|
||||
{socket => <ProductDetailPage seoName={seoName} navigate={navigate} location={location} socket={socket} />}
|
||||
</SocketContext.Consumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailWithSocket;
|
||||
256
src/components/ProductFilters.js
Normal file
256
src/components/ProductFilters.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { Component } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Filter from './Filter.js';
|
||||
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// HOC to provide router props to class components
|
||||
const withRouter = (ClassComponent) => {
|
||||
return (props) => {
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return <ClassComponent
|
||||
{...props}
|
||||
params={params}
|
||||
searchParams={searchParams}
|
||||
navigate={navigate}
|
||||
location={location}
|
||||
/>;
|
||||
};
|
||||
};
|
||||
|
||||
class ProductFilters extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const uniqueManufacturerArray = this._getUniqueManufacturers(this.props.products);
|
||||
const attributeGroups = this._getAttributeGroups(this.props.attributes);
|
||||
const availabilityValues = this._getAvailabilityValues(this.props.products);
|
||||
|
||||
this.state = {
|
||||
availabilityValues,
|
||||
uniqueManufacturerArray,
|
||||
attributeGroups
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Measure the available space dynamically
|
||||
this.adjustPaperHeight();
|
||||
// Add event listener for window resize
|
||||
window.addEventListener('resize', this.adjustPaperHeight);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Remove event listener when component unmounts
|
||||
window.removeEventListener('resize', this.adjustPaperHeight);
|
||||
}
|
||||
|
||||
adjustPaperHeight = () => {
|
||||
// Skip height adjustment on xs screens
|
||||
if (window.innerWidth < 600) return;
|
||||
|
||||
// Get reference to our paper element
|
||||
const paperEl = document.getElementById('filters-paper');
|
||||
if (!paperEl) return;
|
||||
|
||||
// Get viewport height
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Get the offset top position of our paper element
|
||||
const paperTop = paperEl.getBoundingClientRect().top;
|
||||
|
||||
// Estimate footer height (adjust as needed)
|
||||
const footerHeight = 80; // Reduce from 150px
|
||||
|
||||
// Calculate available space and set height
|
||||
const availableHeight = viewportHeight - paperTop - footerHeight;
|
||||
// Add a smaller buffer margin to prevent scrolling but get closer to footer
|
||||
const heightWithBuffer = availableHeight - 20; // Reduce buffer from 50px to 20px
|
||||
paperEl.style.minHeight = `${heightWithBuffer}px`;
|
||||
}
|
||||
|
||||
_getUniqueManufacturers = (products) => {
|
||||
const manufacturers = {};
|
||||
|
||||
for (const product of products)
|
||||
if (!manufacturers[product.manufacturerId])
|
||||
manufacturers[product.manufacturerId] = product.manufacturer;
|
||||
|
||||
const uniqueManufacturerArray = Object.entries(manufacturers)
|
||||
.filter(([_id, name]) => name !== null) // Filter out null names
|
||||
.map(([id, name]) => ({
|
||||
id: parseInt(id),
|
||||
name: name
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return uniqueManufacturerArray;
|
||||
}
|
||||
|
||||
_getAvailabilityValues = (products) => {
|
||||
const filters = [{id:1,name:'auf Lager'}];
|
||||
|
||||
for(const product of products){
|
||||
if(isNew(product.neu)){
|
||||
if(!filters.find(filter => filter.id == 2)) filters.push({id:2,name:'Neu'});
|
||||
}
|
||||
if(!product.available && product.incomingDate){
|
||||
if(!filters.find(filter => filter.id == 3)) filters.push({id:3,name:'Bald verfügbar'});
|
||||
}
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
_getAttributeGroups = (attributes) => {
|
||||
const attributeGroups = {};
|
||||
if(attributes) for(const attribute of attributes) {
|
||||
if(!attributeGroups[attribute.cName]) attributeGroups[attribute.cName] = {name:attribute.cName, values:{}};
|
||||
attributeGroups[attribute.cName].values[attribute.kMerkmalWert] = {id:attribute.kMerkmalWert, name:attribute.cWert};
|
||||
}
|
||||
return attributeGroups;
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
if(nextProps.products !== this.props.products) {
|
||||
const uniqueManufacturerArray = this._getUniqueManufacturers(nextProps.products);
|
||||
const availabilityValues = this._getAvailabilityValues(nextProps.products);
|
||||
this.setState({uniqueManufacturerArray, availabilityValues});
|
||||
}
|
||||
if(nextProps.attributes !== this.props.attributes) {
|
||||
const attributeGroups = this._getAttributeGroups(nextProps.attributes);
|
||||
this.setState({attributeGroups});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
generateAttributeFilters = () => {
|
||||
const filters = [];
|
||||
const sortedAttributeGroups = Object.values(this.state.attributeGroups)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for(const attributeGroup of sortedAttributeGroups) {
|
||||
const filter = (
|
||||
<Filter
|
||||
key={`attr-filter-${attributeGroup.name}`}
|
||||
title={attributeGroup.name}
|
||||
options={Object.values(attributeGroup.values)}
|
||||
filterType="attribute"
|
||||
products={this.props.products}
|
||||
filteredProducts={this.props.filteredProducts}
|
||||
attributes={this.props.attributes}
|
||||
onFilterChange={(msg)=>{
|
||||
if(msg.value) {
|
||||
setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');
|
||||
} else {
|
||||
removeSessionSetting(`filter_${msg.type}_${msg.name}`);
|
||||
}
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
filters.push(filter);
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Paper
|
||||
id="filters-paper"
|
||||
elevation={1}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
|
||||
{this.props.dataType == 'category' && (
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h1"
|
||||
sx={{
|
||||
mb: 4,
|
||||
fontFamily: 'SwashingtonCP',
|
||||
color: 'primary.main'
|
||||
}}
|
||||
>
|
||||
{this.props.dataParam}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
|
||||
{this.props.products.length > 0 && (
|
||||
<><Filter
|
||||
title="Verfügbarkeit"
|
||||
options={this.state.availabilityValues}
|
||||
searchParams={this.props.searchParams}
|
||||
products={this.props.products}
|
||||
filteredProducts={this.props.filteredProducts}
|
||||
attributes={this.props.attributes}
|
||||
filterType="availability"
|
||||
onFilterChange={(msg)=>{
|
||||
|
||||
if(msg.resetAll) {
|
||||
sessionStorage.removeItem('filter_availability');
|
||||
clearAllSessionSettings();
|
||||
this.props.onFilterChange();
|
||||
return;
|
||||
}
|
||||
|
||||
if(!msg.value) {
|
||||
console.log('msg',msg);
|
||||
if(msg.name == '1') sessionStorage.setItem('filter_availability', msg.name);
|
||||
if(msg.name != '1') removeSessionSetting(`filter_${msg.type}_${msg.name}`);
|
||||
//this.props.navigate({
|
||||
// pathname: this.props.location.pathname,
|
||||
// search: `?inStock=${msg.name}`
|
||||
//});
|
||||
} else {
|
||||
if(msg.name == '1') sessionStorage.removeItem('filter_availability');
|
||||
if(msg.name != '1') setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');
|
||||
console.log('msg',msg);
|
||||
//this.props.navigate({
|
||||
// pathname: this.props.location.pathname,
|
||||
// search: this.props.location.search.replace(/inStock=[^&]*/, '')
|
||||
//});
|
||||
}
|
||||
|
||||
this.props.onFilterChange();
|
||||
|
||||
|
||||
}}
|
||||
/>
|
||||
|
||||
{this.generateAttributeFilters()}
|
||||
|
||||
<Filter
|
||||
title="Hersteller"
|
||||
options={this.state.uniqueManufacturerArray}
|
||||
filterType="manufacturer"
|
||||
products={this.props.products}
|
||||
filteredProducts={this.props.filteredProducts}
|
||||
attributes={this.props.attributes}
|
||||
onFilterChange={(msg)=>{
|
||||
if(msg.value) {
|
||||
setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');
|
||||
} else {
|
||||
removeSessionSetting(`filter_${msg.type}_${msg.name}`);
|
||||
}
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
/>
|
||||
</>)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(ProductFilters);
|
||||
347
src/components/ProductList.js
Normal file
347
src/components/ProductList.js
Normal file
@@ -0,0 +1,347 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Pagination from '@mui/material/Pagination';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Product from './Product.js';
|
||||
import { removeSessionSetting } from '../utils/sessionStorage.js';
|
||||
|
||||
// Sort products by fuzzy similarity to their name/description
|
||||
function sortProductsByFuzzySimilarity(products, searchTerm) {
|
||||
console.log('sortProductsByFuzzySimilarity',products,searchTerm);
|
||||
// Create an array that preserves the product object and its searchable text
|
||||
const productsWithText = products.map(product => {
|
||||
const searchableText = `${product.name || ''} ${product.description || ''}`;
|
||||
return { product, searchableText };
|
||||
});
|
||||
|
||||
// Sort products based on their searchable text similarity
|
||||
productsWithText.sort((a, b) => {
|
||||
const scoreA = getFuzzySimilarityScore(a.searchableText, searchTerm);
|
||||
const scoreB = getFuzzySimilarityScore(b.searchableText, searchTerm);
|
||||
return scoreB - scoreA; // Higher scores first
|
||||
});
|
||||
|
||||
// Return just the sorted product objects
|
||||
return productsWithText.map(item => item.product);
|
||||
}
|
||||
|
||||
// Calculate a similarity score between text and search term
|
||||
function getFuzzySimilarityScore(text, searchTerm) {
|
||||
const searchWords = searchTerm.toLowerCase().split(/\W+/).filter(Boolean);
|
||||
const textWords = text.toLowerCase().split(/\W+/).filter(Boolean);
|
||||
|
||||
let totalScore = 0;
|
||||
for (let searchWord of searchWords) {
|
||||
// Exact matches get highest priority
|
||||
if (textWords.includes(searchWord)) {
|
||||
totalScore += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Partial matches get scored based on similarity
|
||||
let bestMatch = 0;
|
||||
for (let textWord of textWords) {
|
||||
if (textWord.includes(searchWord) || searchWord.includes(textWord)) {
|
||||
const similarity = Math.min(searchWord.length, textWord.length) /
|
||||
Math.max(searchWord.length, textWord.length);
|
||||
if (similarity > bestMatch) bestMatch = similarity;
|
||||
}
|
||||
}
|
||||
totalScore += bestMatch;
|
||||
}
|
||||
|
||||
return totalScore;
|
||||
}
|
||||
|
||||
|
||||
class ProductList extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
viewMode: window.productListViewMode || 'grid',
|
||||
products:[],
|
||||
page: window.productListPage || 1,
|
||||
itemsPerPage: window.productListItemsPerPage || 20,
|
||||
sortBy: window.currentSearchQuery ? 'searchField' : 'name'
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
this.handleSearchQuery = () => {
|
||||
this.setState({ sortBy: window.currentSearchQuery ? 'searchField' : 'name' });
|
||||
};
|
||||
window.addEventListener('search-query-change', this.handleSearchQuery);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('search-query-change', this.handleSearchQuery);
|
||||
}
|
||||
|
||||
handleViewModeChange = (viewMode) => {
|
||||
this.setState({ viewMode });
|
||||
window.productListViewMode = viewMode;
|
||||
}
|
||||
|
||||
handlePageChange = (event, value) => {
|
||||
this.setState({ page: value });
|
||||
window.productListPage = value;
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const currentPageCapacity = this.state.itemsPerPage === 'all' ? Infinity : this.state.itemsPerPage;
|
||||
if(this.props.products.length > 0 ) if (this.props.products.length < (currentPageCapacity * (this.state.page-1)) ) {
|
||||
if(this.state.page != 1) this.setState({ page: 1 });
|
||||
window.productListPage = 1;
|
||||
}
|
||||
}
|
||||
|
||||
handleProductsPerPageChange = (event) => {
|
||||
const newItemsPerPage = event.target.value;
|
||||
const newState = { itemsPerPage: newItemsPerPage };
|
||||
window.productListItemsPerPage = newItemsPerPage;
|
||||
|
||||
if(newItemsPerPage!=='all'){
|
||||
const newTotalPages = Math.ceil(this.props.products.length / newItemsPerPage);
|
||||
if (this.state.page > newTotalPages) {
|
||||
newState.page = newTotalPages;
|
||||
window.productListPage = newTotalPages;
|
||||
}
|
||||
}
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
handleSortChange = (event) => {
|
||||
const sortBy = event.target.value;
|
||||
this.setState({ sortBy });
|
||||
}
|
||||
|
||||
renderPagination = (pages, page) => {
|
||||
return (
|
||||
<Box sx={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'left' }}>
|
||||
{((this.state.itemsPerPage==='all')||(this.props.products.length<this.state.itemsPerPage))?null:
|
||||
<Pagination
|
||||
count={pages}
|
||||
page={page}
|
||||
onChange={this.handlePageChange}
|
||||
color="primary"
|
||||
size={"large"}
|
||||
siblingCount={window.innerWidth < 600 ? 0 : 1}
|
||||
boundaryCount={window.innerWidth < 600 ? 1 : 1}
|
||||
hideNextButton={false}
|
||||
hidePrevButton={false}
|
||||
showFirstButton={window.innerWidth >= 600}
|
||||
showLastButton={window.innerWidth >= 600}
|
||||
sx={{
|
||||
'& .MuiPagination-ul': {
|
||||
flexWrap: 'nowrap',
|
||||
overflowX: 'auto',
|
||||
maxWidth: '100%'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
//console.log('products',this.props.activeAttributeFilters,this.props.activeManufacturerFilters,window.currentSearchQuery,this.state.sortBy);
|
||||
|
||||
const filteredProducts = (this.state.sortBy==='searchField')&&(window.currentSearchQuery)?sortProductsByFuzzySimilarity(this.props.products, window.currentSearchQuery):this.state.sortBy==='name'?this.props.products:this.props.products.sort((a,b)=>{
|
||||
if(this.state.sortBy==='price-low-high'){
|
||||
return a.price-b.price;
|
||||
}
|
||||
if(this.state.sortBy==='price-high-low'){
|
||||
return b.price-a.price;
|
||||
}
|
||||
});
|
||||
const products = this.state.itemsPerPage==='all'?[...filteredProducts]:filteredProducts.slice((this.state.page - 1) * this.state.itemsPerPage , this.state.page * this.state.itemsPerPage);
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%' }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{this.props.activeAttributeFilters.map((filter,index) => (
|
||||
<Chip
|
||||
size="medium"
|
||||
key={index}
|
||||
label={filter.value}
|
||||
onClick={() => {
|
||||
removeSessionSetting(`filter_attribute_${filter.id}`);
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
onDelete={() => {
|
||||
removeSessionSetting(`filter_attribute_${filter.id}`);
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
clickable
|
||||
/>
|
||||
))}
|
||||
{this.props.activeManufacturerFilters.map((filter,index) => (
|
||||
<Chip
|
||||
size="medium"
|
||||
key={index}
|
||||
label={filter.name}
|
||||
onClick={() => {
|
||||
removeSessionSetting(`filter_manufacturer_${filter.value}`);
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
onDelete={() => {
|
||||
removeSessionSetting(`filter_manufacturer_${filter.value}`);
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
clickable
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
{/* Sort Dropdown */}
|
||||
<FormControl variant="outlined" size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel id="sort-by-label">Sortierung</InputLabel>
|
||||
<Select
|
||||
size="small"
|
||||
labelId="sort-by-label"
|
||||
value={(this.state.sortBy==='searchField')&&(window.currentSearchQuery)?this.state.sortBy:this.state.sortBy==='price-low-high'?this.state.sortBy:this.state.sortBy==='price-low-high'?this.state.sortBy:'name'}
|
||||
onChange={this.handleSortChange}
|
||||
label="Sortierung"
|
||||
MenuProps={{
|
||||
disableScrollLock: true,
|
||||
anchorOrigin: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
},
|
||||
PaperProps: {
|
||||
sx: {
|
||||
maxHeight: 200,
|
||||
boxShadow: 3,
|
||||
mt: 0.5
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="name">Name</MenuItem>
|
||||
{window.currentSearchQuery && <MenuItem value="searchField">Suchbegriff</MenuItem>}
|
||||
<MenuItem value="price-low-high">Preis: Niedrig zu Hoch</MenuItem>
|
||||
<MenuItem value="price-high-low">Preis: Hoch zu Niedrig</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Per Page Dropdown */}
|
||||
<FormControl variant="outlined" size="small" sx={{ minWidth: 100 }}>
|
||||
<InputLabel id="products-per-page-label">pro Seite</InputLabel>
|
||||
<Select
|
||||
labelId="products-per-page-label"
|
||||
value={this.state.itemsPerPage}
|
||||
onChange={this.handleProductsPerPageChange}
|
||||
label="pro Seite"
|
||||
MenuProps={{
|
||||
disableScrollLock: true,
|
||||
anchorOrigin: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
},
|
||||
PaperProps: {
|
||||
sx: {
|
||||
maxHeight: 200,
|
||||
boxShadow: 3,
|
||||
mt: 0.5,
|
||||
position: 'absolute',
|
||||
zIndex: 999
|
||||
}
|
||||
},
|
||||
container: document.getElementById('root')
|
||||
}}
|
||||
>
|
||||
<MenuItem value={20}>20</MenuItem>
|
||||
<MenuItem value={50}>50</MenuItem>
|
||||
<MenuItem value="all">Alle</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
{ this.renderPagination(Math.ceil(filteredProducts.length / this.state.itemsPerPage), this.state.page) }
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{/*this.props.dataType == 'category' && (<>Kategorie: {this.props.dataParam}</>)}*/}
|
||||
{this.props.dataType == 'search' && (<>Suchergebnisse für: "{this.props.dataParam}"</>)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{
|
||||
this.props.totalProductCount==this.props.products.length && this.props.totalProductCount>0 ?
|
||||
`${this.props.totalProductCount} Produkte`
|
||||
:
|
||||
`${this.props.products.length} von ${this.props.totalProductCount} Produkte`
|
||||
}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{products.map((product) => (
|
||||
<Grid
|
||||
key={product.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: { xs: 'stretch', sm: 'center' },
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
<Product
|
||||
id={product.id}
|
||||
name={product.name}
|
||||
seoName={product.seoName}
|
||||
price={product.price}
|
||||
currency={product.currency}
|
||||
available={product.available}
|
||||
manufacturer={product.manufacturer}
|
||||
vat={product.vat}
|
||||
massMenge={product.massMenge}
|
||||
massEinheit={product.massEinheit}
|
||||
incoming={product.incomingDate}
|
||||
neu={product.neu}
|
||||
thc={product.thc}
|
||||
floweringWeeks={product.floweringWeeks}
|
||||
versandklasse={product.versandklasse}
|
||||
weight={product.weight}
|
||||
socket={this.props.socket}
|
||||
pictureList={product.pictureList}
|
||||
availableSupplier={product.availableSupplier}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{this.renderPagination(Math.ceil(filteredProducts.length / this.state.itemsPerPage), this.state.page)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductList;
|
||||
14
src/components/ScrollToTop.js
Normal file
14
src/components/ScrollToTop.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const ScrollToTop = () => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
121
src/components/Stripe.js
Normal file
121
src/components/Stripe.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { Component, useState } from "react";
|
||||
import { Elements, PaymentElement } from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { Button } from "@mui/material";
|
||||
import config from "../config.js";
|
||||
|
||||
import { useStripe, useElements } from "@stripe/react-stripe-js";
|
||||
|
||||
const CheckoutForm = () => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState(null);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/profile?complete`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error.message);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<PaymentElement />
|
||||
<Button variant="contained" disabled={!stripe} style={{ marginTop: "20px" }} type="submit">
|
||||
Bezahlung Abschließen
|
||||
</Button>
|
||||
{errorMessage && <div>{errorMessage}</div>}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
class Stripe extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
stripe: null,
|
||||
loading: true,
|
||||
elements: null,
|
||||
};
|
||||
this.stripePromise = loadStripe(config.stripePublishableKey);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.stripePromise.then((stripe) => {
|
||||
this.setState({ stripe, loading: false });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { clientSecret } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.state.loading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<Elements
|
||||
stripe={this.stripePromise}
|
||||
options={{
|
||||
appearance: {
|
||||
theme: "stripe",
|
||||
variables: {
|
||||
// Core colors matching your green theme
|
||||
colorPrimary: '#2E7D32', // Your primary forest green
|
||||
colorBackground: '#ffffff', // White background (matches your paper color)
|
||||
colorText: '#33691E', // Your primary text color (dark green)
|
||||
colorTextSecondary: '#558B2F', // Your secondary text color
|
||||
colorTextPlaceholder: '#81C784', // Light green for placeholder text
|
||||
colorDanger: '#D32F2F', // Your error color (red)
|
||||
colorSuccess: '#43A047', // Your success color
|
||||
colorWarning: '#FF9800', // Orange for warnings
|
||||
|
||||
// Typography matching your Roboto setup
|
||||
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif",
|
||||
fontSizeBase: '16px', // Base font size for mobile compatibility
|
||||
fontWeightNormal: '400', // Normal Roboto weight
|
||||
fontWeightMedium: '500', // Medium Roboto weight
|
||||
fontWeightBold: '700', // Bold Roboto weight
|
||||
|
||||
// Layout and spacing
|
||||
spacingUnit: '4px', // Consistent spacing
|
||||
borderRadius: '8px', // Rounded corners matching your style
|
||||
|
||||
// Background variations
|
||||
colorBackgroundDeemphasized: '#C8E6C9', // Your light green background
|
||||
|
||||
// Focus and interaction states
|
||||
focusBoxShadow: '0 0 0 2px #4CAF50', // Green focus ring
|
||||
focusOutline: 'none',
|
||||
|
||||
// Icons to match your green theme
|
||||
iconColor: '#558B2F', // Secondary green for icons
|
||||
iconHoverColor: '#2E7D32', // Primary green on hover
|
||||
}
|
||||
},
|
||||
clientSecret: clientSecret,
|
||||
}}
|
||||
>
|
||||
<CheckoutForm />
|
||||
</Elements>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Stripe;
|
||||
258
src/components/ThemeCustomizerDialog.js
Normal file
258
src/components/ThemeCustomizerDialog.js
Normal file
@@ -0,0 +1,258 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
TextField,
|
||||
Chip,
|
||||
Grid,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import PaletteIcon from '@mui/icons-material/Palette';
|
||||
|
||||
const ThemeCustomizerDialog = ({ open, onClose, theme, onThemeChange }) => {
|
||||
const [localTheme, setLocalTheme] = useState(theme);
|
||||
|
||||
// @note Theme customizer for development - allows real-time theme changes
|
||||
useEffect(() => {
|
||||
setLocalTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
const handleColorChange = (path, value) => {
|
||||
const pathArray = path.split('.');
|
||||
const newTheme = { ...localTheme };
|
||||
let current = newTheme;
|
||||
|
||||
for (let i = 0; i < pathArray.length - 1; i++) {
|
||||
current = current[pathArray[i]];
|
||||
}
|
||||
current[pathArray[pathArray.length - 1]] = value;
|
||||
|
||||
setLocalTheme(newTheme);
|
||||
onThemeChange(newTheme);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const resetTheme = () => {
|
||||
const defaultTheme = {
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#2E7D32',
|
||||
light: '#4CAF50',
|
||||
dark: '#1B5E20',
|
||||
},
|
||||
secondary: {
|
||||
main: '#81C784',
|
||||
light: '#A5D6A7',
|
||||
dark: '#66BB6A',
|
||||
},
|
||||
background: {
|
||||
default: '#C8E6C9',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
text: {
|
||||
primary: '#33691E',
|
||||
secondary: '#558B2F',
|
||||
},
|
||||
success: {
|
||||
main: '#43A047',
|
||||
},
|
||||
error: {
|
||||
main: '#D32F2F',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif",
|
||||
h4: {
|
||||
fontWeight: 600,
|
||||
color: '#33691E',
|
||||
},
|
||||
},
|
||||
};
|
||||
setLocalTheme(defaultTheme);
|
||||
onThemeChange(defaultTheme);
|
||||
};
|
||||
|
||||
const ColorPicker = ({ label, path, value }) => (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>{label}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TextField
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => handleColorChange(path, e.target.value)}
|
||||
sx={{ width: 50, height: 35 }}
|
||||
/>
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={(e) => handleColorChange(path, e.target.value)}
|
||||
size="small"
|
||||
sx={{ flex: 1, fontSize: '0.75rem' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PaletteIcon />
|
||||
Theme Customizer (Development Mode)
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Chip
|
||||
label="DEV ONLY"
|
||||
color="warning"
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
This tool is only available in development mode for theme customization.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Primary Colors</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Main"
|
||||
path="palette.primary.main"
|
||||
value={localTheme.palette.primary.main}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Light"
|
||||
path="palette.primary.light"
|
||||
value={localTheme.palette.primary.light}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Dark"
|
||||
path="palette.primary.dark"
|
||||
value={localTheme.palette.primary.dark}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Secondary Colors</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Main"
|
||||
path="palette.secondary.main"
|
||||
value={localTheme.palette.secondary.main}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Light"
|
||||
path="palette.secondary.light"
|
||||
value={localTheme.palette.secondary.light}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Dark"
|
||||
path="palette.secondary.dark"
|
||||
value={localTheme.palette.secondary.dark}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Background & Text</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={6}>
|
||||
<ColorPicker
|
||||
label="Background"
|
||||
path="palette.background.default"
|
||||
value={localTheme.palette.background.default}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Paper"
|
||||
path="palette.background.paper"
|
||||
value={localTheme.palette.background.paper}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<ColorPicker
|
||||
label="Text Primary"
|
||||
path="palette.text.primary"
|
||||
value={localTheme.palette.text.primary}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Text Secondary"
|
||||
path="palette.text.secondary"
|
||||
value={localTheme.palette.text.secondary}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Status Colors</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={6}>
|
||||
<ColorPicker
|
||||
label="Success"
|
||||
path="palette.success.main"
|
||||
value={localTheme.palette.success.main}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<ColorPicker
|
||||
label="Error"
|
||||
path="palette.error.main"
|
||||
value={localTheme.palette.error.main}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={resetTheme} color="warning">
|
||||
Reset to Default
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeCustomizerDialog;
|
||||
159
src/components/configurator/ExtrasSelector.js
Normal file
159
src/components/configurator/ExtrasSelector.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { Component } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
|
||||
class ExtrasSelector extends Component {
|
||||
formatPrice(price) {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
renderExtraCard(extra) {
|
||||
const { selectedExtras, onExtraToggle, showImage = true } = this.props;
|
||||
const isSelected = selectedExtras.includes(extra.id);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={extra.id}
|
||||
sx={{
|
||||
height: '100%',
|
||||
border: '2px solid',
|
||||
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
|
||||
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
|
||||
'&:hover': {
|
||||
boxShadow: 5,
|
||||
borderColor: isSelected ? '#2e7d32' : '#90caf9'
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => onExtraToggle(extra.id)}
|
||||
>
|
||||
{showImage && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="160"
|
||||
image={extra.image}
|
||||
alt={extra.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onExtraToggle(extra.id);
|
||||
}}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
'&.Mui-checked': { color: '#2e7d32' },
|
||||
padding: 0
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label=""
|
||||
sx={{ margin: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{this.formatPrice(extra.price)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{extra.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{extra.description}
|
||||
</Typography>
|
||||
|
||||
{isSelected && (
|
||||
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
✓ Hinzugefügt
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { extras, title, subtitle, groupByCategory = true, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
|
||||
|
||||
if (groupByCategory) {
|
||||
// Group extras by category
|
||||
const groupedExtras = extras.reduce((acc, extra) => {
|
||||
if (!acc[extra.category]) {
|
||||
acc[extra.category] = [];
|
||||
}
|
||||
acc[extra.category].push(extra);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{Object.entries(groupedExtras).map(([category, categoryExtras]) => (
|
||||
<Box key={category} sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1976d2' }}>
|
||||
{category}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{categoryExtras.map(extra => (
|
||||
<Grid item {...gridSize} key={extra.id}>
|
||||
{this.renderExtraCard(extra)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render without category grouping
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{extras.map(extra => (
|
||||
<Grid item {...gridSize} key={extra.id}>
|
||||
{this.renderExtraCard(extra)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ExtrasSelector;
|
||||
170
src/components/configurator/ProductSelector.js
Normal file
170
src/components/configurator/ProductSelector.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { Component } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
|
||||
class ProductSelector extends Component {
|
||||
formatPrice(price) {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
renderProductCard(product) {
|
||||
const { selectedValue, onSelect, showImage = true } = this.props;
|
||||
const isSelected = selectedValue === product.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={product.id}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: '2px solid',
|
||||
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
|
||||
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
|
||||
'&:hover': {
|
||||
boxShadow: 6,
|
||||
borderColor: isSelected ? '#2e7d32' : '#90caf9'
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
height: '100%'
|
||||
}}
|
||||
onClick={() => onSelect(product.id)}
|
||||
>
|
||||
{showImage && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="180"
|
||||
image={product.image}
|
||||
alt={product.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{product.description}
|
||||
</Typography>
|
||||
|
||||
{/* Product specific information */}
|
||||
{this.renderProductDetails(product)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{this.formatPrice(product.price)}
|
||||
</Typography>
|
||||
{isSelected && (
|
||||
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
✓ Ausgewählt
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
renderProductDetails(product) {
|
||||
const { productType } = this.props;
|
||||
|
||||
switch (productType) {
|
||||
case 'tent':
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Maße:</strong> {product.dimensions}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Für:</strong> {product.coverage}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'light':
|
||||
return (
|
||||
<Box sx={{ mt: 2, mb: 2 }}>
|
||||
<Chip
|
||||
label={product.wattage}
|
||||
size="small"
|
||||
sx={{ mr: 1, mb: 1, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Chip
|
||||
label={product.coverage}
|
||||
size="small"
|
||||
sx={{ mr: 1, mb: 1, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Chip
|
||||
label={product.spectrum}
|
||||
size="small"
|
||||
sx={{ mr: 1, mb: 1, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Effizienz: ${product.efficiency}`}
|
||||
size="small"
|
||||
sx={{ mb: 1, pointerEvents: 'none' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'ventilation':
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Luftdurchsatz:</strong> {product.airflow}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Lautstärke:</strong> {product.noiseLevel}
|
||||
</Typography>
|
||||
{product.includes && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>Beinhaltet:</strong>
|
||||
</Typography>
|
||||
{product.includes.map((item, index) => (
|
||||
<Typography key={index} variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
• {item}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { products, title, subtitle, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{products.map(product => (
|
||||
<Grid item {...gridSize} key={product.id}>
|
||||
{this.renderProductCard(product)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductSelector;
|
||||
241
src/components/configurator/TentShapeSelector.js
Normal file
241
src/components/configurator/TentShapeSelector.js
Normal file
@@ -0,0 +1,241 @@
|
||||
import React, { Component } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
|
||||
class TentShapeSelector extends Component {
|
||||
// Generate plant layout based on tent shape
|
||||
generatePlantLayout(shapeId) {
|
||||
const layouts = {
|
||||
'60x60': [
|
||||
{ x: 50, y: 50, size: 18 } // 1 large plant centered
|
||||
],
|
||||
'80x80': [
|
||||
{ x: 35, y: 35, size: 12 }, // 2x2 = 4 plants
|
||||
{ x: 65, y: 35, size: 12 },
|
||||
{ x: 35, y: 65, size: 12 },
|
||||
{ x: 65, y: 65, size: 12 }
|
||||
],
|
||||
'100x100': [
|
||||
{ x: 22, y: 22, size: 10 }, // 3x3 = 9 plants
|
||||
{ x: 50, y: 22, size: 10 },
|
||||
{ x: 78, y: 22, size: 10 },
|
||||
{ x: 22, y: 50, size: 10 },
|
||||
{ x: 50, y: 50, size: 10 },
|
||||
{ x: 78, y: 50, size: 10 },
|
||||
{ x: 22, y: 78, size: 10 },
|
||||
{ x: 50, y: 78, size: 10 },
|
||||
{ x: 78, y: 78, size: 10 }
|
||||
],
|
||||
'120x60': [
|
||||
{ x: 30, y: 50, size: 14 }, // 1x3 = 3 larger plants
|
||||
{ x: 50, y: 50, size: 14 },
|
||||
{ x: 70, y: 50, size: 14 }
|
||||
]
|
||||
};
|
||||
|
||||
return layouts[shapeId] || [];
|
||||
}
|
||||
|
||||
renderShapeCard(shape) {
|
||||
const { selectedShape, onShapeSelect } = this.props;
|
||||
const isSelected = selectedShape === shape.id;
|
||||
|
||||
const plants = this.generatePlantLayout(shape.id);
|
||||
|
||||
// Make visual sizes proportional to actual dimensions
|
||||
let visualWidth, visualHeight;
|
||||
switch(shape.id) {
|
||||
case '60x60':
|
||||
visualWidth = 90;
|
||||
visualHeight = 90;
|
||||
break;
|
||||
case '80x80':
|
||||
visualWidth = 110;
|
||||
visualHeight = 110;
|
||||
break;
|
||||
case '100x100':
|
||||
visualWidth = 130;
|
||||
visualHeight = 130;
|
||||
break;
|
||||
case '120x60':
|
||||
visualWidth = 140;
|
||||
visualHeight = 80;
|
||||
break;
|
||||
default:
|
||||
visualWidth = 120;
|
||||
visualHeight = 120;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={shape.id}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: '3px solid',
|
||||
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
|
||||
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
|
||||
'&:hover': {
|
||||
boxShadow: 8,
|
||||
borderColor: isSelected ? '#2e7d32' : '#90caf9',
|
||||
transform: 'translateY(-2px)'
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
height: '100%',
|
||||
minHeight: 300
|
||||
}}
|
||||
onClick={() => onShapeSelect(shape.id)}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', p: 3 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{shape.name}
|
||||
</Typography>
|
||||
|
||||
{/* Enhanced visual representation with plant layout */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: visualHeight,
|
||||
mb: 2,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${visualWidth}px`,
|
||||
height: `${visualHeight}px`,
|
||||
border: '3px solid #2e7d32',
|
||||
borderRadius: 2,
|
||||
backgroundColor: isSelected ? '#e8f5e8' : '#f5f5f5',
|
||||
position: 'relative',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Grid pattern */}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<pattern id={`grid-${shape.id}`} width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M 10 0 L 0 0 0 10" fill="none" stroke="#e0e0e0" strokeWidth="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#grid-${shape.id})`} />
|
||||
|
||||
{/* Plants */}
|
||||
{plants.map((plant, index) => (
|
||||
<circle
|
||||
key={index}
|
||||
cx={`${plant.x}%`}
|
||||
cy={`${plant.y}%`}
|
||||
r={plant.size}
|
||||
fill="#4caf50"
|
||||
fillOpacity="0.8"
|
||||
stroke="#2e7d32"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Dimensions label */}
|
||||
<Typography variant="caption" sx={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontWeight: 'bold',
|
||||
color: '#2e7d32',
|
||||
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||
px: 1,
|
||||
borderRadius: 1,
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
{shape.footprint}
|
||||
</Typography>
|
||||
|
||||
{/* Plant count label */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
backgroundColor: 'rgba(46, 125, 50, 0.9)',
|
||||
color: 'white',
|
||||
px: 1,
|
||||
borderRadius: 1,
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{plants.length} 🌱
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{shape.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Chip
|
||||
label={`${shape.minPlants}-${shape.maxPlants} Pflanzen`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: isSelected ? '#2e7d32' : '#f0f0f0',
|
||||
color: isSelected ? 'white' : 'inherit',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
fontWeight: 'bold',
|
||||
opacity: isSelected ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease'
|
||||
}}
|
||||
>
|
||||
✓ Ausgewählt
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { tentShapes, title, subtitle } = this.props;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Grid container spacing={3}>
|
||||
{tentShapes.map(shape => (
|
||||
<Grid item xs={12} sm={6} md={3} key={shape.id}>
|
||||
{this.renderShapeCard(shape)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TentShapeSelector;
|
||||
3
src/components/configurator/index.js
Normal file
3
src/components/configurator/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as TentShapeSelector } from './TentShapeSelector.js';
|
||||
export { default as ProductSelector } from './ProductSelector.js';
|
||||
export { default as ExtrasSelector } from './ExtrasSelector.js';
|
||||
198
src/components/header/ButtonGroup.js
Normal file
198
src/components/header/ButtonGroup.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import LoginComponent from '../LoginComponent.js';
|
||||
import CartDropdown from '../CartDropdown.js';
|
||||
import { isUserLoggedIn } from '../LoginComponent.js';
|
||||
|
||||
function getBadgeNumber() {
|
||||
let count = 0;
|
||||
if (Array.isArray(window.cart)) for (const item of window.cart) {
|
||||
if (item.quantity) count += item.quantity;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
class ButtonGroup extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isCartOpen: false,
|
||||
badgeNumber: getBadgeNumber()
|
||||
};
|
||||
this.isUpdatingFromSocket = false; // @note Flag to prevent socket loop
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.cart = () => {
|
||||
// @note Only emit if socket exists, is connected, AND the update didn't come from socket
|
||||
if (this.props.socket && this.props.socket.connected && !this.isUpdatingFromSocket) {
|
||||
this.props.socket.emit('updateCart', window.cart);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
badgeNumber: getBadgeNumber()
|
||||
});
|
||||
};
|
||||
window.addEventListener('cart', this.cart);
|
||||
|
||||
// Add event listener for the toggle-cart event from AddToCartButton
|
||||
this.toggleCartListener = () => this.toggleCart();
|
||||
window.addEventListener('toggle-cart', this.toggleCartListener);
|
||||
|
||||
// Add socket listeners if socket is available and connected
|
||||
this.addSocketListeners();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected) {
|
||||
// Socket just connected, add listeners
|
||||
this.addSocketListeners();
|
||||
} else if (wasConnected && !isNowConnected) {
|
||||
// Socket just disconnected, remove listeners
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('cart', this.cart);
|
||||
window.removeEventListener('toggle-cart', this.toggleCartListener);
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
handleCartUpdated = (id,user,cart) => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(storedUser);
|
||||
if(user && parsedUser &&user.email == parsedUser.email){
|
||||
// @note Set flag before updating cart to prevent socket loop
|
||||
this.isUpdatingFromSocket = true;
|
||||
window.cart = cart;
|
||||
this.setState({
|
||||
badgeNumber: getBadgeNumber()
|
||||
});
|
||||
// @note Reset flag after a short delay to allow for any synchronous events
|
||||
setTimeout(() => {
|
||||
this.isUpdatingFromSocket = false;
|
||||
}, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing user from sessionStorage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
toggleCart = () => {
|
||||
this.setState(prevState => ({
|
||||
isCartOpen: !prevState.isCartOpen
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { socket, navigate } = this.props;
|
||||
const { isCartOpen } = this.state;
|
||||
const cartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 1 } }}>
|
||||
|
||||
|
||||
<LoginComponent socket={socket} />
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<Badge badgeContent={this.state.badgeNumber} color="error">
|
||||
<ShoppingCartIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
<Drawer
|
||||
anchor="left"
|
||||
open={isCartOpen}
|
||||
onClose={this.toggleCart}
|
||||
disableScrollLock={true}
|
||||
>
|
||||
<Box sx={{ width: 420, p: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={this.toggleCart}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.dark',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6">Warenkorb</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<CartDropdown cartItems={cartItems} socket={socket} onClose={this.toggleCart} onCheckout={()=>{
|
||||
/*open the Drawer inside <LoginComponent */
|
||||
|
||||
if (isUserLoggedIn().isLoggedIn) {
|
||||
this.toggleCart(); // Close the cart drawer
|
||||
navigate('/profile');
|
||||
} else if (window.openLoginDrawer) {
|
||||
window.openLoginDrawer(); // Call global function to open login drawer
|
||||
this.toggleCart(); // Close the cart drawer
|
||||
} else {
|
||||
console.error('openLoginDrawer function not available');
|
||||
}
|
||||
}}/>
|
||||
|
||||
</Box>
|
||||
</Drawer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper for ButtonGroup to provide navigate function
|
||||
const ButtonGroupWithRouter = (props) => {
|
||||
const navigate = useNavigate();
|
||||
return <ButtonGroup {...props} navigate={navigate} />;
|
||||
};
|
||||
|
||||
export default ButtonGroupWithRouter;
|
||||
481
src/components/header/CategoryList.js
Normal file
481
src/components/header/CategoryList.js
Normal file
@@ -0,0 +1,481 @@
|
||||
import React, { Component, Profiler } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Container from "@mui/material/Container";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Link } from "react-router-dom";
|
||||
import HomeIcon from "@mui/icons-material/Home";
|
||||
|
||||
class CategoryList extends Component {
|
||||
findCategoryById = (category, targetId) => {
|
||||
if (!category) return null;
|
||||
|
||||
if (category.seoName === targetId) {
|
||||
return category;
|
||||
}
|
||||
|
||||
if (category.children) {
|
||||
for (let child of category.children) {
|
||||
const found = this.findCategoryById(child, targetId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
getPathToCategory = (category, targetId, currentPath = []) => {
|
||||
if (!category) return null;
|
||||
|
||||
const newPath = [...currentPath, category];
|
||||
|
||||
if (category.seoName === targetId) {
|
||||
return newPath;
|
||||
}
|
||||
|
||||
if (category.children) {
|
||||
for (let child of category.children) {
|
||||
const found = this.getPathToCategory(child, targetId, newPath);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Check for cached data during SSR/initial render
|
||||
let initialState = {
|
||||
categoryTree: null,
|
||||
level1Categories: [], // Children of category 209 (Home) - always shown
|
||||
level2Categories: [], // Children of active level 1 category
|
||||
level3Categories: [], // Children of active level 2 category
|
||||
activePath: [], // Array of active category objects for each level
|
||||
fetchedCategories: false,
|
||||
};
|
||||
|
||||
// Try to get cached data for SSR
|
||||
try {
|
||||
// @note Check both global.window (SSR) and window (browser) for cache
|
||||
const productCache = (typeof global !== "undefined" && global.window && global.window.productCache) ||
|
||||
(typeof window !== "undefined" && window.productCache);
|
||||
|
||||
if (productCache) {
|
||||
const cacheKey = "categoryTree_209";
|
||||
const cachedData = productCache[cacheKey];
|
||||
if (cachedData && cachedData.categoryTree) {
|
||||
const { categoryTree, timestamp } = cachedData;
|
||||
const cacheAge = Date.now() - timestamp;
|
||||
const tenMinutes = 10 * 60 * 1000;
|
||||
|
||||
// Use cached data if it's fresh
|
||||
if (cacheAge < tenMinutes) {
|
||||
initialState.categoryTree = categoryTree;
|
||||
initialState.fetchedCategories = true;
|
||||
|
||||
// Process category tree to set up navigation
|
||||
const level1Categories =
|
||||
categoryTree && categoryTree.id === 209
|
||||
? categoryTree.children || []
|
||||
: [];
|
||||
initialState.level1Categories = level1Categories;
|
||||
|
||||
// Process active category path if needed
|
||||
if (props.activeCategoryId) {
|
||||
const activeCategory = this.findCategoryById(
|
||||
categoryTree,
|
||||
props.activeCategoryId
|
||||
);
|
||||
if (activeCategory) {
|
||||
const pathToActive = this.getPathToCategory(
|
||||
categoryTree,
|
||||
props.activeCategoryId
|
||||
);
|
||||
initialState.activePath = pathToActive
|
||||
? pathToActive.slice(1)
|
||||
: [];
|
||||
|
||||
if (initialState.activePath.length >= 1) {
|
||||
const level1Category = initialState.activePath[0];
|
||||
initialState.level2Categories = level1Category.children || [];
|
||||
}
|
||||
|
||||
if (initialState.activePath.length >= 2) {
|
||||
const level2Category = initialState.activePath[1];
|
||||
initialState.level3Categories = level2Category.children || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error reading cache in constructor:", err);
|
||||
}
|
||||
|
||||
this.state = initialState;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchCategories();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected && !this.state.fetchedCategories) {
|
||||
// Socket just connected and we haven't fetched categories yet
|
||||
this.setState(
|
||||
{
|
||||
fetchedCategories: false,
|
||||
},
|
||||
() => {
|
||||
this.fetchCategories();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// If activeCategoryId changes, update subcategories
|
||||
if (
|
||||
prevProps.activeCategoryId !== this.props.activeCategoryId &&
|
||||
this.state.categoryTree
|
||||
) {
|
||||
this.processCategoryTree(this.state.categoryTree);
|
||||
}
|
||||
}
|
||||
|
||||
fetchCategories = () => {
|
||||
const { socket } = this.props;
|
||||
if (!socket || !socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch categories");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.fetchedCategories) {
|
||||
//console.log('Categories already fetched, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize global cache object if it doesn't exist
|
||||
// @note Handle both SSR (global.window) and browser (window) environments
|
||||
const windowObj = (typeof global !== "undefined" && global.window) ||
|
||||
(typeof window !== "undefined" && window);
|
||||
|
||||
if (windowObj && !windowObj.productCache) {
|
||||
windowObj.productCache = {};
|
||||
}
|
||||
|
||||
// Check if we have a valid cache in the global object
|
||||
try {
|
||||
const cacheKey = "categoryTree_209";
|
||||
const cachedData = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
|
||||
if (cachedData) {
|
||||
const { categoryTree, fetching } = cachedData;
|
||||
//const cacheAge = Date.now() - timestamp;
|
||||
//const tenMinutes = 10 * 60 * 1000; // 10 minutes in milliseconds
|
||||
|
||||
// If data is currently being fetched, wait for it
|
||||
if (fetching) {
|
||||
//console.log('CategoryList: Data is being fetched, waiting...');
|
||||
const checkInterval = setInterval(() => {
|
||||
const currentCache = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
|
||||
if (currentCache && !currentCache.fetching) {
|
||||
clearInterval(checkInterval);
|
||||
if (currentCache.categoryTree) {
|
||||
this.processCategoryTree(currentCache.categoryTree);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// If cache is less than 10 minutes old, use it
|
||||
if (/*cacheAge < tenMinutes &&*/ categoryTree) {
|
||||
//console.log('Using cached category tree, age:', Math.round(cacheAge/1000), 'seconds');
|
||||
// Defer processing to next tick to avoid blocking
|
||||
//setTimeout(() => {
|
||||
this.processCategoryTree(categoryTree);
|
||||
//}, 0);
|
||||
//return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error reading from cache:", err);
|
||||
}
|
||||
|
||||
// Mark as being fetched to prevent concurrent calls
|
||||
const cacheKey = "categoryTree_209";
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
fetching: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
this.setState({ fetchedCategories: true });
|
||||
|
||||
//console.log('CategoryList: Fetching categories from socket');
|
||||
socket.emit("categoryList", { categoryId: 209 }, (response) => {
|
||||
if (response && response.categoryTree) {
|
||||
//console.log('Category tree received:', response.categoryTree);
|
||||
|
||||
// Store in global cache with timestamp
|
||||
try {
|
||||
const cacheKey = "categoryTree_209";
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
categoryTree: response.categoryTree,
|
||||
timestamp: Date.now(),
|
||||
fetching: false,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error writing to cache:", err);
|
||||
}
|
||||
|
||||
this.processCategoryTree(response.categoryTree);
|
||||
} else {
|
||||
try {
|
||||
const cacheKey = "categoryTree_209";
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
categoryTree: null,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error writing to cache:", err);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
categoryTree: null,
|
||||
level1Categories: [],
|
||||
level2Categories: [],
|
||||
level3Categories: [],
|
||||
activePath: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
processCategoryTree = (categoryTree) => {
|
||||
// Level 1 categories are always the children of category 209 (Home)
|
||||
const level1Categories =
|
||||
categoryTree && categoryTree.id === 209
|
||||
? categoryTree.children || []
|
||||
: [];
|
||||
|
||||
// Build the navigation path and determine what to show at each level
|
||||
let level2Categories = [];
|
||||
let level3Categories = [];
|
||||
let activePath = [];
|
||||
|
||||
if (this.props.activeCategoryId) {
|
||||
const activeCategory = this.findCategoryById(
|
||||
categoryTree,
|
||||
this.props.activeCategoryId
|
||||
);
|
||||
if (activeCategory) {
|
||||
// Build the path from root to active category
|
||||
const pathToActive = this.getPathToCategory(
|
||||
categoryTree,
|
||||
this.props.activeCategoryId
|
||||
);
|
||||
activePath = pathToActive.slice(1); // Remove root (209) from path
|
||||
|
||||
// Determine what to show at each level based on the path depth
|
||||
if (activePath.length >= 1) {
|
||||
// Show children of the level 1 category
|
||||
const level1Category = activePath[0];
|
||||
level2Categories = level1Category.children || [];
|
||||
}
|
||||
|
||||
if (activePath.length >= 2) {
|
||||
// Show children of the level 2 category
|
||||
const level2Category = activePath[1];
|
||||
level3Categories = level2Category.children || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
categoryTree,
|
||||
level1Categories,
|
||||
level2Categories,
|
||||
level3Categories,
|
||||
activePath,
|
||||
fetchedCategories: true,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { level1Categories, level2Categories, level3Categories, activePath } =
|
||||
this.state;
|
||||
|
||||
const renderCategoryRow = (categories, level = 1) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
flexWrap: "nowrap",
|
||||
overflowX: "auto",
|
||||
py: 0.5, // Add vertical padding to prevent border clipping
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none",
|
||||
},
|
||||
scrollbarWidth: "none",
|
||||
msOverflowStyle: "none",
|
||||
}}
|
||||
>
|
||||
{level === 1 && (
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
color="inherit"
|
||||
size="small"
|
||||
aria-label="Zur Startseite"
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: "normal",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
mx: 0.5,
|
||||
my: 0.25, // Add consistent vertical margin to account for borders
|
||||
minWidth: "auto",
|
||||
border: "2px solid transparent", // Always have border space
|
||||
borderRadius: 1, // Always have border radius
|
||||
...(this.props.activeCategoryId === null && {
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
|
||||
transform: "translateY(-2px)",
|
||||
bgcolor: "rgba(255,255,255,0.25)",
|
||||
borderColor: "rgba(255,255,255,0.6)", // Change border color instead of adding border
|
||||
fontWeight: "bold",
|
||||
opacity: 1,
|
||||
}),
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
bgcolor: "rgba(255,255,255,0.15)",
|
||||
transform: "translateY(-1px)",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.3)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<HomeIcon sx={{ fontSize: "1rem" }} />
|
||||
</Button>
|
||||
)}
|
||||
{this.state.fetchedCategories && categories.length > 0 ? (
|
||||
<>
|
||||
{categories.map((category) => {
|
||||
// Determine if this category is active at this level
|
||||
const isActiveAtThisLevel =
|
||||
activePath[level - 1] &&
|
||||
activePath[level - 1].id === category.id;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
component={Link}
|
||||
to={`/Kategorie/${category.seoName}`}
|
||||
color="inherit"
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: "normal",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
mx: 0.5,
|
||||
my: 0.25, // Add consistent vertical margin to account for borders
|
||||
border: "2px solid transparent", // Always have border space
|
||||
borderRadius: 1, // Always have border radius
|
||||
...(isActiveAtThisLevel && {
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
|
||||
transform: "translateY(-2px)",
|
||||
bgcolor: "rgba(255,255,255,0.25)",
|
||||
borderColor: "rgba(255,255,255,0.6)", // Change border color instead of adding border
|
||||
fontWeight: "bold",
|
||||
opacity: 1,
|
||||
}),
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
bgcolor: "rgba(255,255,255,0.15)",
|
||||
transform: "translateY(-1px)",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.3)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
level === 1 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="inherit"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
height: "30px", // Match small button height
|
||||
px: 1,
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const onRenderCallback = (id, phase, actualDuration) => {
|
||||
if (actualDuration > 50) {
|
||||
console.warn(
|
||||
`CategoryList render took ${actualDuration}ms in ${phase} phase`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Profiler id="CategoryList" onRender={onRenderCallback}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
bgcolor: "primary.dark",
|
||||
display: { xs: "none", md: "block" },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg" sx={{ px: 2 }}>
|
||||
{/* Level 1 Categories Row - Always shown */}
|
||||
{renderCategoryRow(level1Categories, 1)}
|
||||
|
||||
{/* Level 2 Categories Row - Show when level 1 is selected */}
|
||||
{level2Categories.length > 0 && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{renderCategoryRow(level2Categories, 2)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Level 3 Categories Row - Show when level 2 is selected */}
|
||||
{level3Categories.length > 0 && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{renderCategoryRow(level3Categories, 3)}
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
</Profiler>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CategoryList;
|
||||
27
src/components/header/Logo.js
Normal file
27
src/components/header/Logo.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const Logo = () => {
|
||||
return (
|
||||
<Box
|
||||
component={Link}
|
||||
to="/"
|
||||
aria-label="Zur Startseite"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/assets/images/sh.png"
|
||||
alt="SH Logo"
|
||||
style={{ height: "45px" }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
310
src/components/header/SearchBar.js
Normal file
310
src/components/header/SearchBar.js
Normal file
@@ -0,0 +1,310 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
|
||||
const SearchBar = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const socket = React.useContext(SocketContext);
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
// State management
|
||||
const [searchQuery, setSearchQuery] = React.useState(
|
||||
searchParams.get("q") || ""
|
||||
);
|
||||
const [suggestions, setSuggestions] = React.useState([]);
|
||||
const [showSuggestions, setShowSuggestions] = React.useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(-1);
|
||||
const [loadingSuggestions, setLoadingSuggestions] = React.useState(false);
|
||||
|
||||
// Refs for debouncing and timers
|
||||
const debounceTimerRef = React.useRef(null);
|
||||
const autocompleteTimerRef = React.useRef(null);
|
||||
const isFirstKeystrokeRef = React.useRef(true);
|
||||
const inputRef = React.useRef(null);
|
||||
const suggestionBoxRef = React.useRef(null);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
delete window.currentSearchQuery;
|
||||
setShowSuggestions(false);
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSearchState = (value) => {
|
||||
setSearchQuery(value);
|
||||
|
||||
// Dispatch global custom event with search query value
|
||||
const searchEvent = new CustomEvent("search-query-change", {
|
||||
detail: { query: value },
|
||||
});
|
||||
// Store the current search query in the window object
|
||||
window.currentSearchQuery = value;
|
||||
window.dispatchEvent(searchEvent);
|
||||
};
|
||||
|
||||
// @note Autocomplete function using getSearchProducts Socket.io API - returns objects with name and seoName
|
||||
const fetchAutocomplete = React.useCallback(
|
||||
(query) => {
|
||||
if (!socket || !query || query.length < 2) {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
setLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSuggestions(true);
|
||||
|
||||
socket.emit(
|
||||
"getSearchProducts",
|
||||
{
|
||||
query: query.trim(),
|
||||
limit: 8,
|
||||
},
|
||||
(response) => {
|
||||
setLoadingSuggestions(false);
|
||||
|
||||
if (response && response.products) {
|
||||
// getSearchProducts returns response.products array
|
||||
const suggestions = response.products.slice(0, 8); // Limit to 8 suggestions
|
||||
setSuggestions(suggestions);
|
||||
setShowSuggestions(suggestions.length > 0);
|
||||
setSelectedIndex(-1); // Reset selection
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
console.log("getSearchProducts failed or no products:", response);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[socket]
|
||||
);
|
||||
|
||||
const handleSearchChange = (e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
// Always update the input field immediately for responsiveness
|
||||
setSearchQuery(value);
|
||||
|
||||
// Clear any existing timers
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (autocompleteTimerRef.current) {
|
||||
clearTimeout(autocompleteTimerRef.current);
|
||||
}
|
||||
|
||||
// Set the debounce timer for search state update
|
||||
const delay = isFirstKeystrokeRef.current ? 100 : 200;
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
updateSearchState(value);
|
||||
isFirstKeystrokeRef.current = false;
|
||||
|
||||
// Reset first keystroke flag after 1 second of inactivity
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
isFirstKeystrokeRef.current = true;
|
||||
}, 1000);
|
||||
}, delay);
|
||||
|
||||
// Set autocomplete timer with longer delay to reduce API calls
|
||||
autocompleteTimerRef.current = setTimeout(() => {
|
||||
fetchAutocomplete(value);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Handle keyboard navigation in suggestions
|
||||
const handleKeyDown = (e) => {
|
||||
if (!showSuggestions || suggestions.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < suggestions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||
const selectedSuggestion = suggestions[selectedIndex];
|
||||
setSearchQuery(selectedSuggestion.name);
|
||||
updateSearchState(selectedSuggestion.name);
|
||||
setShowSuggestions(false);
|
||||
navigate(`/Artikel/${selectedSuggestion.seoName}`);
|
||||
} else {
|
||||
handleSearch(e);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle suggestion click - navigate to product page directly
|
||||
const handleSuggestionClick = (suggestion) => {
|
||||
setSearchQuery(suggestion.name);
|
||||
updateSearchState(suggestion.name);
|
||||
setShowSuggestions(false);
|
||||
navigate(`/Artikel/${suggestion.seoName}`);
|
||||
};
|
||||
|
||||
// Handle input focus
|
||||
const handleFocus = () => {
|
||||
if (suggestions.length > 0 && searchQuery.length >= 2) {
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle input blur with delay to allow suggestion clicks
|
||||
const handleBlur = () => {
|
||||
setTimeout(() => {
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Clean up timers on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (autocompleteTimerRef.current) {
|
||||
clearTimeout(autocompleteTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Close suggestions when clicking outside
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (
|
||||
suggestionBoxRef.current &&
|
||||
!suggestionBoxRef.current.contains(event.target) &&
|
||||
!inputRef.current?.contains(event.target)
|
||||
) {
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleSearch}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
mx: { xs: 1, sm: 2, md: 4 },
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
ref={inputRef}
|
||||
placeholder="Produkte suchen..."
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: loadingSuggestions && (
|
||||
<InputAdornment position="end">
|
||||
<CircularProgress size={16} />
|
||||
</InputAdornment>
|
||||
),
|
||||
sx: { borderRadius: 2, bgcolor: "background.paper" },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Autocomplete Suggestions Dropdown */}
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<Paper
|
||||
ref={suggestionBoxRef}
|
||||
elevation={4}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1300,
|
||||
maxHeight: "300px",
|
||||
overflow: "auto",
|
||||
mt: 0.5,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<List disablePadding>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<ListItem
|
||||
key={suggestion.seoName || index}
|
||||
button
|
||||
selected={index === selectedIndex}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
backgroundColor: "action.hover",
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: "action.selected",
|
||||
"&:hover": {
|
||||
backgroundColor: "action.selected",
|
||||
},
|
||||
},
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" noWrap>
|
||||
{suggestion.name}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
4
src/components/header/index.js
Normal file
4
src/components/header/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Logo } from './Logo.js';
|
||||
export { default as SearchBar } from './SearchBar.js';
|
||||
export { default as ButtonGroupWithRouter } from './ButtonGroup.js';
|
||||
export { default as CategoryList } from './CategoryList.js';
|
||||
138
src/components/profile/AddressForm.js
Normal file
138
src/components/profile/AddressForm.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import React from "react";
|
||||
import { Box, TextField, Typography } from "@mui/material";
|
||||
|
||||
const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
||||
// Helper function to determine if a required field should show error styling
|
||||
const getRequiredFieldError = (fieldName, value) => {
|
||||
const isEmpty = !value || value.trim() === "";
|
||||
return isEmpty;
|
||||
};
|
||||
|
||||
// Helper function to get label styling for required fields
|
||||
const getRequiredFieldLabelSx = (fieldName, value) => {
|
||||
const showError = getRequiredFieldError(fieldName, value);
|
||||
return showError
|
||||
? {
|
||||
"&.MuiInputLabel-shrink": {
|
||||
color: "#d32f2f", // Material-UI error color
|
||||
},
|
||||
}
|
||||
: {};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Vorname"
|
||||
name="firstName"
|
||||
value={address.firstName}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}FirstName`]}
|
||||
helperText={errors[`${namePrefix}FirstName`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("firstName", address.firstName),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Nachname"
|
||||
name="lastName"
|
||||
value={address.lastName}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}LastName`]}
|
||||
helperText={errors[`${namePrefix}LastName`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("lastName", address.lastName),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Adresszusatz"
|
||||
name="addressAddition"
|
||||
value={address.addressAddition || ""}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
label="Straße"
|
||||
name="street"
|
||||
value={address.street}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}Street`]}
|
||||
helperText={errors[`${namePrefix}Street`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("street", address.street),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Hausnummer"
|
||||
name="houseNumber"
|
||||
value={address.houseNumber}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}HouseNumber`]}
|
||||
helperText={errors[`${namePrefix}HouseNumber`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("houseNumber", address.houseNumber),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="PLZ"
|
||||
name="postalCode"
|
||||
value={address.postalCode}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}PostalCode`]}
|
||||
helperText={errors[`${namePrefix}PostalCode`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("postalCode", address.postalCode),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Stadt"
|
||||
name="city"
|
||||
value={address.city}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}City`]}
|
||||
helperText={errors[`${namePrefix}City`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("city", address.city),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Land"
|
||||
name="country"
|
||||
value={address.country}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
disabled
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddressForm;
|
||||
510
src/components/profile/CartTab.js
Normal file
510
src/components/profile/CartTab.js
Normal file
@@ -0,0 +1,510 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Typography, Button } from "@mui/material";
|
||||
import CartDropdown from "../CartDropdown.js";
|
||||
import CheckoutForm from "./CheckoutForm.js";
|
||||
import PaymentConfirmationDialog from "./PaymentConfirmationDialog.js";
|
||||
import OrderProcessingService from "./OrderProcessingService.js";
|
||||
import CheckoutValidation from "./CheckoutValidation.js";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
|
||||
class CartTab extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const initialCartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
const initialDeliveryMethod = CheckoutValidation.shouldForcePickupDelivery(initialCartItems) ? "Abholung" : "DHL";
|
||||
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(initialDeliveryMethod, initialCartItems, 0);
|
||||
|
||||
this.state = {
|
||||
isCheckingOut: false,
|
||||
cartItems: initialCartItems,
|
||||
deliveryMethod: initialDeliveryMethod,
|
||||
paymentMethod: optimalPaymentMethod,
|
||||
invoiceAddress: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
addressAddition: "",
|
||||
street: "",
|
||||
houseNumber: "",
|
||||
postalCode: "",
|
||||
city: "",
|
||||
country: "Deutschland",
|
||||
},
|
||||
deliveryAddress: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
addressAddition: "",
|
||||
street: "",
|
||||
houseNumber: "",
|
||||
postalCode: "",
|
||||
city: "",
|
||||
country: "Deutschland",
|
||||
},
|
||||
useSameAddress: true,
|
||||
saveAddressForFuture: true,
|
||||
addressFormErrors: {},
|
||||
termsAccepted: false,
|
||||
isCompletingOrder: false,
|
||||
completionError: null,
|
||||
note: "",
|
||||
stripeClientSecret: null,
|
||||
showStripePayment: false,
|
||||
StripeComponent: null,
|
||||
isLoadingStripe: false,
|
||||
showPaymentConfirmation: false,
|
||||
orderCompleted: false,
|
||||
originalCartItems: []
|
||||
};
|
||||
|
||||
// Initialize order processing service
|
||||
this.orderService = new OrderProcessingService(
|
||||
() => this.context,
|
||||
this.setState.bind(this)
|
||||
);
|
||||
this.orderService.getState = () => this.state;
|
||||
this.orderService.setOrderSuccessCallback(this.props.onOrderSuccess);
|
||||
}
|
||||
|
||||
// @note Add method to fetch and apply order template prefill data
|
||||
fetchOrderTemplate = () => {
|
||||
if (this.context && this.context.connected) {
|
||||
this.context.emit('getOrderTemplate', (response) => {
|
||||
if (response.success && response.orderTemplate) {
|
||||
const template = response.orderTemplate;
|
||||
|
||||
// Map the template fields to our state structure
|
||||
const invoiceAddress = {
|
||||
firstName: template.invoice_address_name ? template.invoice_address_name.split(' ')[0] || "" : "",
|
||||
lastName: template.invoice_address_name ? template.invoice_address_name.split(' ').slice(1).join(' ') || "" : "",
|
||||
addressAddition: template.invoice_address_line2 || "",
|
||||
street: template.invoice_address_street || "",
|
||||
houseNumber: template.invoice_address_house_number || "",
|
||||
postalCode: template.invoice_address_postal_code || "",
|
||||
city: template.invoice_address_city || "",
|
||||
country: template.invoice_address_country || "Deutschland",
|
||||
};
|
||||
|
||||
const deliveryAddress = {
|
||||
firstName: template.shipping_address_name ? template.shipping_address_name.split(' ')[0] || "" : "",
|
||||
lastName: template.shipping_address_name ? template.shipping_address_name.split(' ').slice(1).join(' ') || "" : "",
|
||||
addressAddition: template.shipping_address_line2 || "",
|
||||
street: template.shipping_address_street || "",
|
||||
houseNumber: template.shipping_address_house_number || "",
|
||||
postalCode: template.shipping_address_postal_code || "",
|
||||
city: template.shipping_address_city || "",
|
||||
country: template.shipping_address_country || "Deutschland",
|
||||
};
|
||||
|
||||
// Get current cart state to check constraints
|
||||
const currentCartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(currentCartItems);
|
||||
|
||||
// Determine delivery method - respect cart constraints
|
||||
let prefillDeliveryMethod = template.delivery_method || "DHL";
|
||||
if (isPickupOnly || hasStecklinge) {
|
||||
prefillDeliveryMethod = "Abholung";
|
||||
}
|
||||
|
||||
// Map delivery method values if needed
|
||||
const deliveryMethodMap = {
|
||||
"standard": "DHL",
|
||||
"express": "DPD",
|
||||
"pickup": "Abholung"
|
||||
};
|
||||
prefillDeliveryMethod = deliveryMethodMap[prefillDeliveryMethod] || prefillDeliveryMethod;
|
||||
|
||||
// Determine payment method - respect constraints
|
||||
let prefillPaymentMethod = template.payment_method || "wire";
|
||||
const paymentMethodMap = {
|
||||
"credit_card": "stripe",
|
||||
"bank_transfer": "wire",
|
||||
"cash_on_delivery": "onDelivery",
|
||||
"cash": "cash"
|
||||
};
|
||||
prefillPaymentMethod = paymentMethodMap[prefillPaymentMethod] || prefillPaymentMethod;
|
||||
|
||||
// Validate payment method against delivery method constraints
|
||||
prefillPaymentMethod = CheckoutValidation.validatePaymentMethodForDelivery(
|
||||
prefillDeliveryMethod,
|
||||
prefillPaymentMethod,
|
||||
currentCartItems,
|
||||
0 // Use 0 for delivery cost during prefill
|
||||
);
|
||||
|
||||
// Apply prefill data to state
|
||||
this.setState({
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
deliveryMethod: prefillDeliveryMethod,
|
||||
paymentMethod: prefillPaymentMethod,
|
||||
saveAddressForFuture: template.save_address_for_future === 1,
|
||||
useSameAddress: true // Default to same address, user can change if needed
|
||||
});
|
||||
|
||||
console.log("Order template applied successfully");
|
||||
} else {
|
||||
console.log("No order template available or failed to fetch");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// Handle payment completion if detected
|
||||
if (this.props.paymentCompletion) {
|
||||
this.orderService.handlePaymentCompletion(
|
||||
this.props.paymentCompletion,
|
||||
this.props.onClearPaymentCompletion
|
||||
);
|
||||
}
|
||||
|
||||
// @note Fetch order template for prefill when component mounts
|
||||
this.fetchOrderTemplate();
|
||||
|
||||
this.cart = () => {
|
||||
// @note Don't update cart if we're showing payment confirmation - keep it empty
|
||||
if (this.state.showPaymentConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
const shouldForcePickup = CheckoutValidation.shouldForcePickupDelivery(cartItems);
|
||||
|
||||
const newDeliveryMethod = shouldForcePickup ? "Abholung" : this.state.deliveryMethod;
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
|
||||
// Get optimal payment method for the current state
|
||||
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(
|
||||
newDeliveryMethod,
|
||||
cartItems,
|
||||
deliveryCost
|
||||
);
|
||||
|
||||
// Use optimal payment method if current one is invalid, otherwise keep current
|
||||
const validatedPaymentMethod = CheckoutValidation.validatePaymentMethodForDelivery(
|
||||
newDeliveryMethod,
|
||||
this.state.paymentMethod,
|
||||
cartItems,
|
||||
deliveryCost
|
||||
);
|
||||
|
||||
const newPaymentMethod = validatedPaymentMethod !== this.state.paymentMethod
|
||||
? optimalPaymentMethod
|
||||
: this.state.paymentMethod;
|
||||
|
||||
this.setState({
|
||||
cartItems,
|
||||
deliveryMethod: newDeliveryMethod,
|
||||
paymentMethod: newPaymentMethod,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("cart", this.cart);
|
||||
this.cart(); // Initial check
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("cart", this.cart);
|
||||
this.orderService.cleanup();
|
||||
}
|
||||
|
||||
handleCheckout = () => {
|
||||
this.setState({ isCheckingOut: true });
|
||||
};
|
||||
|
||||
handleContinueShopping = () => {
|
||||
this.setState({ isCheckingOut: false });
|
||||
};
|
||||
|
||||
handleDeliveryMethodChange = (event) => {
|
||||
const newDeliveryMethod = event.target.value;
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
|
||||
// Get optimal payment method for the new delivery method
|
||||
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(
|
||||
newDeliveryMethod,
|
||||
this.state.cartItems,
|
||||
deliveryCost
|
||||
);
|
||||
|
||||
// Use optimal payment method if current one becomes invalid, otherwise keep current
|
||||
const validatedPaymentMethod = CheckoutValidation.validatePaymentMethodForDelivery(
|
||||
newDeliveryMethod,
|
||||
this.state.paymentMethod,
|
||||
this.state.cartItems,
|
||||
deliveryCost
|
||||
);
|
||||
|
||||
const newPaymentMethod = validatedPaymentMethod !== this.state.paymentMethod
|
||||
? optimalPaymentMethod
|
||||
: this.state.paymentMethod;
|
||||
|
||||
this.setState({
|
||||
deliveryMethod: newDeliveryMethod,
|
||||
paymentMethod: newPaymentMethod,
|
||||
});
|
||||
};
|
||||
|
||||
handlePaymentMethodChange = (event) => {
|
||||
this.setState({ paymentMethod: event.target.value });
|
||||
};
|
||||
|
||||
handleInvoiceAddressChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
this.setState((prevState) => ({
|
||||
invoiceAddress: {
|
||||
...prevState.invoiceAddress,
|
||||
[name]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
handleDeliveryAddressChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
this.setState((prevState) => ({
|
||||
deliveryAddress: {
|
||||
...prevState.deliveryAddress,
|
||||
[name]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
handleUseSameAddressChange = (e) => {
|
||||
const useSameAddress = e.target.checked;
|
||||
this.setState({
|
||||
useSameAddress,
|
||||
deliveryAddress: useSameAddress
|
||||
? this.state.invoiceAddress
|
||||
: this.state.deliveryAddress,
|
||||
});
|
||||
};
|
||||
|
||||
handleTermsAcceptedChange = (e) => {
|
||||
this.setState({ termsAccepted: e.target.checked });
|
||||
};
|
||||
|
||||
handleNoteChange = (e) => {
|
||||
this.setState({ note: e.target.value });
|
||||
};
|
||||
|
||||
handleSaveAddressForFutureChange = (e) => {
|
||||
this.setState({ saveAddressForFuture: e.target.checked });
|
||||
};
|
||||
|
||||
validateAddressForm = () => {
|
||||
const errors = CheckoutValidation.validateAddressForm(this.state);
|
||||
this.setState({ addressFormErrors: errors });
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
loadStripeComponent = async (clientSecret) => {
|
||||
this.setState({ isLoadingStripe: true });
|
||||
|
||||
try {
|
||||
const { default: Stripe } = await import("../Stripe.js");
|
||||
this.setState({
|
||||
StripeComponent: Stripe,
|
||||
stripeClientSecret: clientSecret,
|
||||
showStripePayment: true,
|
||||
isCompletingOrder: false,
|
||||
isLoadingStripe: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load Stripe component:", error);
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
isLoadingStripe: false,
|
||||
completionError: "Failed to load payment component. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleCompleteOrder = () => {
|
||||
this.setState({ completionError: null }); // Clear previous errors
|
||||
|
||||
const validationError = CheckoutValidation.getValidationErrorMessage(this.state);
|
||||
if (validationError) {
|
||||
this.setState({ completionError: validationError });
|
||||
this.validateAddressForm(); // To show field-specific errors
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isCompletingOrder: true });
|
||||
|
||||
const {
|
||||
deliveryMethod,
|
||||
paymentMethod,
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
cartItems,
|
||||
note,
|
||||
saveAddressForFuture,
|
||||
} = this.state;
|
||||
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
|
||||
// Handle Stripe payment differently
|
||||
if (paymentMethod === "stripe") {
|
||||
// Store the cart items used for Stripe payment in sessionStorage for later reference
|
||||
try {
|
||||
sessionStorage.setItem('stripePaymentCart', JSON.stringify(cartItems));
|
||||
} catch (error) {
|
||||
console.error("Failed to store Stripe payment cart:", error);
|
||||
}
|
||||
|
||||
// Calculate total amount for Stripe
|
||||
const subtotal = cartItems.reduce(
|
||||
(total, item) => total + item.price * item.quantity,
|
||||
0
|
||||
);
|
||||
const totalAmount = Math.round((subtotal + deliveryCost) * 100); // Convert to cents
|
||||
|
||||
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle regular orders
|
||||
const orderData = {
|
||||
items: cartItems,
|
||||
invoiceAddress,
|
||||
deliveryAddress: useSameAddress ? invoiceAddress : deliveryAddress,
|
||||
deliveryMethod,
|
||||
paymentMethod,
|
||||
deliveryCost,
|
||||
note,
|
||||
domain: window.location.origin,
|
||||
saveAddressForFuture,
|
||||
};
|
||||
|
||||
this.orderService.processRegularOrder(orderData);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
cartItems,
|
||||
deliveryMethod,
|
||||
paymentMethod,
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
saveAddressForFuture,
|
||||
addressFormErrors,
|
||||
termsAccepted,
|
||||
isCompletingOrder,
|
||||
completionError,
|
||||
note,
|
||||
stripeClientSecret,
|
||||
showStripePayment,
|
||||
StripeComponent,
|
||||
isLoadingStripe,
|
||||
showPaymentConfirmation,
|
||||
orderCompleted,
|
||||
} = this.state;
|
||||
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(cartItems);
|
||||
|
||||
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state);
|
||||
const displayError = completionError || preSubmitError;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Payment Confirmation */}
|
||||
{showPaymentConfirmation && (
|
||||
<PaymentConfirmationDialog
|
||||
paymentCompletionData={this.orderService.paymentCompletionData}
|
||||
isCompletingOrder={isCompletingOrder}
|
||||
completionError={completionError}
|
||||
orderCompleted={orderCompleted}
|
||||
onContinueShopping={() => {
|
||||
this.setState({ showPaymentConfirmation: false });
|
||||
}}
|
||||
onViewOrders={() => {
|
||||
if (this.props.onOrderSuccess) {
|
||||
this.props.onOrderSuccess();
|
||||
}
|
||||
this.setState({ showPaymentConfirmation: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* @note Hide CartDropdown when showing payment confirmation */}
|
||||
{!showPaymentConfirmation && (
|
||||
<CartDropdown
|
||||
cartItems={cartItems}
|
||||
socket={this.context}
|
||||
showDetailedSummary={showStripePayment}
|
||||
deliveryMethod={deliveryMethod}
|
||||
deliveryCost={deliveryCost}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cartItems.length > 0 && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{isLoadingStripe ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body1">
|
||||
Zahlungskomponente wird geladen...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : showStripePayment && StripeComponent ? (
|
||||
<>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => this.setState({ showStripePayment: false, stripeClientSecret: null })}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
borderColor: '#2e7d32',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(46, 125, 50, 0.04)',
|
||||
borderColor: '#1b5e20'
|
||||
}
|
||||
}}
|
||||
>
|
||||
← Zurück zur Bestellung
|
||||
</Button>
|
||||
</Box>
|
||||
<StripeComponent clientSecret={stripeClientSecret} />
|
||||
</>
|
||||
) : (
|
||||
<CheckoutForm
|
||||
paymentMethod={paymentMethod}
|
||||
invoiceAddress={invoiceAddress}
|
||||
deliveryAddress={deliveryAddress}
|
||||
useSameAddress={useSameAddress}
|
||||
saveAddressForFuture={saveAddressForFuture}
|
||||
addressFormErrors={addressFormErrors}
|
||||
termsAccepted={termsAccepted}
|
||||
note={note}
|
||||
deliveryMethod={deliveryMethod}
|
||||
hasStecklinge={hasStecklinge}
|
||||
isPickupOnly={isPickupOnly}
|
||||
deliveryCost={deliveryCost}
|
||||
cartItems={cartItems}
|
||||
displayError={displayError}
|
||||
isCompletingOrder={isCompletingOrder}
|
||||
preSubmitError={preSubmitError}
|
||||
onInvoiceAddressChange={this.handleInvoiceAddressChange}
|
||||
onDeliveryAddressChange={this.handleDeliveryAddressChange}
|
||||
onUseSameAddressChange={this.handleUseSameAddressChange}
|
||||
onSaveAddressForFutureChange={this.handleSaveAddressForFutureChange}
|
||||
onTermsAcceptedChange={this.handleTermsAcceptedChange}
|
||||
onNoteChange={this.handleNoteChange}
|
||||
onDeliveryMethodChange={this.handleDeliveryMethodChange}
|
||||
onPaymentMethodChange={this.handlePaymentMethodChange}
|
||||
onCompleteOrder={this.handleCompleteOrder}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set static contextType to access the socket
|
||||
CartTab.contextType = SocketContext;
|
||||
|
||||
export default CartTab;
|
||||
185
src/components/profile/CheckoutForm.js
Normal file
185
src/components/profile/CheckoutForm.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Typography, TextField, Checkbox, FormControlLabel, Button } from "@mui/material";
|
||||
import AddressForm from "./AddressForm.js";
|
||||
import DeliveryMethodSelector from "./DeliveryMethodSelector.js";
|
||||
import PaymentMethodSelector from "./PaymentMethodSelector.js";
|
||||
import OrderSummary from "./OrderSummary.js";
|
||||
|
||||
class CheckoutForm extends Component {
|
||||
render() {
|
||||
const {
|
||||
paymentMethod,
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
saveAddressForFuture,
|
||||
addressFormErrors,
|
||||
termsAccepted,
|
||||
note,
|
||||
deliveryMethod,
|
||||
hasStecklinge,
|
||||
isPickupOnly,
|
||||
deliveryCost,
|
||||
cartItems,
|
||||
displayError,
|
||||
isCompletingOrder,
|
||||
preSubmitError,
|
||||
onInvoiceAddressChange,
|
||||
onDeliveryAddressChange,
|
||||
onUseSameAddressChange,
|
||||
onSaveAddressForFutureChange,
|
||||
onTermsAcceptedChange,
|
||||
onNoteChange,
|
||||
onDeliveryMethodChange,
|
||||
onPaymentMethodChange,
|
||||
onCompleteOrder,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{paymentMethod !== "cash" && (
|
||||
<>
|
||||
<AddressForm
|
||||
title="Rechnungsadresse"
|
||||
address={invoiceAddress}
|
||||
onChange={onInvoiceAddressChange}
|
||||
errors={addressFormErrors}
|
||||
namePrefix="invoice"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={saveAddressForFuture}
|
||||
onChange={onSaveAddressForFutureChange}
|
||||
sx={{ '& .MuiSvgIcon-root': { fontSize: 28 } }}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
Für zukünftige Bestellungen speichern
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasStecklinge && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ mb: 2, fontWeight: "bold", color: "#2e7d32" }}
|
||||
>
|
||||
Für welchen Termin ist die Abholung der Stecklinge
|
||||
gewünscht?
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Anmerkung"
|
||||
name="note"
|
||||
value={note}
|
||||
onChange={onNoteChange}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
sx={{ mb: 2 }}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
|
||||
<DeliveryMethodSelector
|
||||
deliveryMethod={deliveryMethod}
|
||||
onChange={onDeliveryMethodChange}
|
||||
isPickupOnly={isPickupOnly || hasStecklinge}
|
||||
/>
|
||||
|
||||
{(deliveryMethod === "DHL" || deliveryMethod === "DPD") && (
|
||||
<>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={useSameAddress}
|
||||
onChange={onUseSameAddressChange}
|
||||
sx={{ '& .MuiSvgIcon-root': { fontSize: 28 } }}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body1">
|
||||
Lieferadresse ist identisch mit Rechnungsadresse
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{!useSameAddress && (
|
||||
<AddressForm
|
||||
title="Lieferadresse"
|
||||
address={deliveryAddress}
|
||||
onChange={onDeliveryAddressChange}
|
||||
errors={addressFormErrors}
|
||||
namePrefix="delivery"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<PaymentMethodSelector
|
||||
paymentMethod={paymentMethod}
|
||||
onChange={onPaymentMethodChange}
|
||||
deliveryMethod={deliveryMethod}
|
||||
onDeliveryMethodChange={onDeliveryMethodChange}
|
||||
cartItems={cartItems}
|
||||
deliveryCost={deliveryCost}
|
||||
/>
|
||||
|
||||
<OrderSummary deliveryCost={deliveryCost} cartItems={cartItems} />
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={termsAccepted}
|
||||
onChange={onTermsAcceptedChange}
|
||||
sx={{
|
||||
'& .MuiSvgIcon-root': { fontSize: 28 },
|
||||
alignSelf: 'flex-start',
|
||||
mt: -0.5
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
Ich habe die AGBs, die Datenschutzerklärung und die
|
||||
Bestimmungen zum Widerrufsrecht gelesen
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 3, mt: 2 }}
|
||||
/>
|
||||
|
||||
{/* @note Reserve space for error message to prevent layout shift */}
|
||||
<Box sx={{ minHeight: '24px', mb: 2, textAlign: "center" }}>
|
||||
{displayError && (
|
||||
<Typography color="error" sx={{ lineHeight: '24px' }}>
|
||||
{displayError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
sx={{ bgcolor: "#2e7d32", "&:hover": { bgcolor: "#1b5e20" } }}
|
||||
onClick={onCompleteOrder}
|
||||
disabled={isCompletingOrder || !!preSubmitError}
|
||||
>
|
||||
{isCompletingOrder
|
||||
? "Bestellung wird verarbeitet..."
|
||||
: "Bestellung abschließen"}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CheckoutForm;
|
||||
150
src/components/profile/CheckoutValidation.js
Normal file
150
src/components/profile/CheckoutValidation.js
Normal file
@@ -0,0 +1,150 @@
|
||||
class CheckoutValidation {
|
||||
static validateAddressForm(state) {
|
||||
const {
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
deliveryMethod,
|
||||
paymentMethod,
|
||||
} = state;
|
||||
const errors = {};
|
||||
|
||||
// Validate invoice address (skip if payment method is "cash")
|
||||
if (paymentMethod !== "cash") {
|
||||
if (!invoiceAddress.firstName)
|
||||
errors.invoiceFirstName = "Vorname erforderlich";
|
||||
if (!invoiceAddress.lastName)
|
||||
errors.invoiceLastName = "Nachname erforderlich";
|
||||
if (!invoiceAddress.street) errors.invoiceStreet = "Straße erforderlich";
|
||||
if (!invoiceAddress.houseNumber)
|
||||
errors.invoiceHouseNumber = "Hausnummer erforderlich";
|
||||
if (!invoiceAddress.postalCode)
|
||||
errors.invoicePostalCode = "PLZ erforderlich";
|
||||
if (!invoiceAddress.city) errors.invoiceCity = "Stadt erforderlich";
|
||||
}
|
||||
|
||||
// Validate delivery address for shipping methods that require it
|
||||
if (
|
||||
!useSameAddress &&
|
||||
(deliveryMethod === "DHL" || deliveryMethod === "DPD")
|
||||
) {
|
||||
if (!deliveryAddress.firstName)
|
||||
errors.deliveryFirstName = "Vorname erforderlich";
|
||||
if (!deliveryAddress.lastName)
|
||||
errors.deliveryLastName = "Nachname erforderlich";
|
||||
if (!deliveryAddress.street)
|
||||
errors.deliveryStreet = "Straße erforderlich";
|
||||
if (!deliveryAddress.houseNumber)
|
||||
errors.deliveryHouseNumber = "Hausnummer erforderlich";
|
||||
if (!deliveryAddress.postalCode)
|
||||
errors.deliveryPostalCode = "PLZ erforderlich";
|
||||
if (!deliveryAddress.city) errors.deliveryCity = "Stadt erforderlich";
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
static getValidationErrorMessage(state, isAddressOnly = false) {
|
||||
const { termsAccepted } = state;
|
||||
|
||||
const addressErrors = this.validateAddressForm(state);
|
||||
|
||||
if (isAddressOnly) {
|
||||
return addressErrors;
|
||||
}
|
||||
|
||||
if (Object.keys(addressErrors).length > 0) {
|
||||
return "Bitte überprüfen Sie Ihre Eingaben in den Adressfeldern.";
|
||||
}
|
||||
|
||||
// Validate terms acceptance
|
||||
if (!termsAccepted) {
|
||||
return "Bitte akzeptieren Sie die AGBs, Datenschutzerklärung und Widerrufsrecht, um fortzufahren.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static getOptimalPaymentMethod(deliveryMethod, cartItems = [], deliveryCost = 0) {
|
||||
// Calculate total amount
|
||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||
const totalAmount = subtotal + deliveryCost;
|
||||
|
||||
// If total is 0, only cash is allowed
|
||||
if (totalAmount === 0) {
|
||||
return "cash";
|
||||
}
|
||||
|
||||
// If total is less than 0.50€, stripe is not available
|
||||
if (totalAmount < 0.50) {
|
||||
return "wire";
|
||||
}
|
||||
|
||||
// Prefer stripe when available and meets minimum amount
|
||||
if (deliveryMethod === "DHL" || deliveryMethod === "DPD" || deliveryMethod === "Abholung") {
|
||||
return "stripe";
|
||||
}
|
||||
|
||||
// Fall back to wire transfer
|
||||
return "wire";
|
||||
}
|
||||
|
||||
static validatePaymentMethodForDelivery(deliveryMethod, paymentMethod, cartItems = [], deliveryCost = 0) {
|
||||
let newPaymentMethod = paymentMethod;
|
||||
|
||||
// Calculate total amount for minimum validation
|
||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||
const totalAmount = subtotal + deliveryCost;
|
||||
|
||||
// Reset payment method if it's no longer valid
|
||||
if (deliveryMethod !== "DHL" && paymentMethod === "onDelivery") {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
// Allow stripe for DHL, DPD, and Abholung delivery methods, but check minimum amount
|
||||
if (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung" && paymentMethod === "stripe") {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
// Check minimum amount for stripe payments
|
||||
if (paymentMethod === "stripe" && totalAmount < 0.50) {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
if (deliveryMethod !== "Abholung" && paymentMethod === "cash") {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
return newPaymentMethod;
|
||||
}
|
||||
|
||||
static shouldForcePickupDelivery(cartItems) {
|
||||
const isPickupOnly = cartItems.some(
|
||||
(item) => item.versandklasse === "nur Abholung"
|
||||
);
|
||||
const hasStecklinge = cartItems.some(
|
||||
(item) =>
|
||||
item.id &&
|
||||
typeof item.id === "string" &&
|
||||
item.id.endsWith("steckling")
|
||||
);
|
||||
|
||||
return isPickupOnly || hasStecklinge;
|
||||
}
|
||||
|
||||
static getCartItemFlags(cartItems) {
|
||||
const isPickupOnly = cartItems.some(
|
||||
(item) => item.versandklasse === "nur Abholung"
|
||||
);
|
||||
const hasStecklinge = cartItems.some(
|
||||
(item) =>
|
||||
item.id &&
|
||||
typeof item.id === "string" &&
|
||||
item.id.endsWith("steckling")
|
||||
);
|
||||
|
||||
return { isPickupOnly, hasStecklinge };
|
||||
}
|
||||
}
|
||||
|
||||
export default CheckoutValidation;
|
||||
122
src/components/profile/DeliveryMethodSelector.js
Normal file
122
src/components/profile/DeliveryMethodSelector.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
|
||||
const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly }) => {
|
||||
const deliveryOptions = [
|
||||
{
|
||||
id: 'DHL',
|
||||
name: 'DHL',
|
||||
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
|
||||
price: '6,99 €',
|
||||
disabled: isPickupOnly
|
||||
},
|
||||
{
|
||||
id: 'DPD',
|
||||
name: 'DPD',
|
||||
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
|
||||
price: '4,90 €',
|
||||
disabled: isPickupOnly
|
||||
},
|
||||
{
|
||||
id: 'Sperrgut',
|
||||
name: 'Sperrgut',
|
||||
description: 'Für große und schwere Artikel',
|
||||
price: '28,99 €',
|
||||
disabled: true,
|
||||
isCheckbox: true
|
||||
},
|
||||
{
|
||||
id: 'Abholung',
|
||||
name: 'Abholung in der Filiale',
|
||||
description: '',
|
||||
price: ''
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Versandart wählen
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{deliveryOptions.map((option, index) => (
|
||||
<Box
|
||||
key={option.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: index < deliveryOptions.length - 1 ? 1 : 0,
|
||||
p: 1,
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 1,
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
backgroundColor: option.disabled ? '#f5f5f5' : 'transparent',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': !option.disabled ? {
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderColor: '#2e7d32',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
} : {},
|
||||
...(deliveryMethod === option.id && !option.disabled && {
|
||||
backgroundColor: '#e8f5e8',
|
||||
borderColor: '#2e7d32'
|
||||
})
|
||||
}}
|
||||
onClick={!option.disabled && !option.isCheckbox ? () => onChange({ target: { value: option.id } }) : undefined}
|
||||
>
|
||||
{option.isCheckbox ? (
|
||||
<Checkbox
|
||||
id={option.id}
|
||||
disabled={option.disabled}
|
||||
checked={false}
|
||||
sx={{ color: 'rgba(0, 0, 0, 0.54)' }}
|
||||
/>
|
||||
) : (
|
||||
<Radio
|
||||
id={option.id}
|
||||
name="deliveryMethod"
|
||||
value={option.id}
|
||||
checked={deliveryMethod === option.id}
|
||||
onChange={onChange}
|
||||
disabled={option.disabled}
|
||||
sx={{ cursor: option.disabled ? 'not-allowed' : 'pointer' }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ ml: 2, flexGrow: 1 }}>
|
||||
<label
|
||||
htmlFor={option.id}
|
||||
style={{
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
color: option.disabled ? 'rgba(0, 0, 0, 0.54)' : 'inherit'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ color: 'inherit' }}>
|
||||
{option.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ color: 'inherit' }}
|
||||
>
|
||||
{option.description}
|
||||
</Typography>
|
||||
</label>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: option.disabled ? 'rgba(0, 0, 0, 0.54)' : 'inherit' }}
|
||||
>
|
||||
{option.price}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeliveryMethodSelector;
|
||||
171
src/components/profile/OrderDetailsDialog.js
Normal file
171
src/components/profile/OrderDetailsDialog.js
Normal file
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
|
||||
const OrderDetailsDialog = ({ open, onClose, order }) => {
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||
|
||||
const handleCancelOrder = () => {
|
||||
// Implement order cancellation logic here
|
||||
console.log(`Cancel order: ${order.orderId}`);
|
||||
onClose(); // Close the dialog after action
|
||||
};
|
||||
|
||||
const subtotal = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
|
||||
const total = subtotal + order.delivery_cost;
|
||||
|
||||
// Calculate VAT breakdown similar to CartDropdown
|
||||
const vatCalculations = order.items.reduce((acc, item) => {
|
||||
const totalItemPrice = item.price * item.quantity_ordered;
|
||||
const netPrice = totalItemPrice / (1 + item.vat / 100);
|
||||
const vatAmount = totalItemPrice - netPrice;
|
||||
|
||||
acc.totalGross += totalItemPrice;
|
||||
acc.totalNet += netPrice;
|
||||
|
||||
if (item.vat === 7) {
|
||||
acc.vat7 += vatAmount;
|
||||
} else if (item.vat === 19) {
|
||||
acc.vat19 += vatAmount;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Bestelldetails: {order.orderId}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h6">Lieferadresse</Typography>
|
||||
<Typography>{order.shipping_address_name}</Typography>
|
||||
<Typography>{order.shipping_address_street} {order.shipping_address_house_number}</Typography>
|
||||
<Typography>{order.shipping_address_postal_code} {order.shipping_address_city}</Typography>
|
||||
<Typography>{order.shipping_address_country}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h6">Rechnungsadresse</Typography>
|
||||
<Typography>{order.invoice_address_name}</Typography>
|
||||
<Typography>{order.invoice_address_street} {order.invoice_address_house_number}</Typography>
|
||||
<Typography>{order.invoice_address_postal_code} {order.invoice_address_city}</Typography>
|
||||
<Typography>{order.invoice_address_country}</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Order Details Section */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Bestelldetails</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 4 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">Lieferart:</Typography>
|
||||
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || 'Nicht angegeben'}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">Zahlungsart:</Typography>
|
||||
<Typography variant="body1">{order.paymentMethod || order.payment_method || 'Nicht angegeben'}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" gutterBottom>Bestellte Artikel</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Artikel</TableCell>
|
||||
<TableCell align="right">Menge</TableCell>
|
||||
<TableCell align="right">Preis</TableCell>
|
||||
<TableCell align="right">Gesamt</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{order.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell align="right">{item.quantity_ordered}</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(item.price)}</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(item.price * item.quantity_ordered)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">Gesamtnettopreis</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">{currencyFormatter.format(vatCalculations.totalNet)}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{vatCalculations.vat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">7% Mehrwertsteuer</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat7)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{vatCalculations.vat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">19% Mehrwertsteuer</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat19)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">Zwischensumme</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">{currencyFormatter.format(subtotal)}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">Lieferkosten</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(order.delivery_cost)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">Gesamtsumme</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">{currencyFormatter.format(total)}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{order.status === 'new' && (
|
||||
<Button onClick={handleCancelOrder} color="error">
|
||||
Bestellung stornieren
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Schließen</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderDetailsDialog;
|
||||
315
src/components/profile/OrderProcessingService.js
Normal file
315
src/components/profile/OrderProcessingService.js
Normal file
@@ -0,0 +1,315 @@
|
||||
import { isUserLoggedIn } from "../LoginComponent.js";
|
||||
|
||||
class OrderProcessingService {
|
||||
constructor(getContext, setState) {
|
||||
this.getContext = getContext;
|
||||
this.setState = setState;
|
||||
this.verifyTokenHandler = null;
|
||||
this.verifyTokenTimeout = null;
|
||||
this.socketHandler = null;
|
||||
this.paymentCompletionData = null;
|
||||
}
|
||||
|
||||
// Clean up all event listeners and timeouts
|
||||
cleanup() {
|
||||
if (this.verifyTokenHandler) {
|
||||
window.removeEventListener('cart', this.verifyTokenHandler);
|
||||
this.verifyTokenHandler = null;
|
||||
}
|
||||
if (this.verifyTokenTimeout) {
|
||||
clearTimeout(this.verifyTokenTimeout);
|
||||
this.verifyTokenTimeout = null;
|
||||
}
|
||||
if (this.socketHandler) {
|
||||
window.removeEventListener('cart', this.socketHandler);
|
||||
this.socketHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle payment completion from parent component
|
||||
handlePaymentCompletion(paymentCompletion, onClearPaymentCompletion) {
|
||||
// Store payment completion data before clearing
|
||||
this.paymentCompletionData = { ...paymentCompletion };
|
||||
|
||||
// Clear payment completion data to prevent duplicates
|
||||
if (onClearPaymentCompletion) {
|
||||
onClearPaymentCompletion();
|
||||
}
|
||||
|
||||
// Show payment confirmation immediately but wait for verifyToken to complete
|
||||
this.setState({
|
||||
showPaymentConfirmation: true,
|
||||
cartItems: [] // Clear UI cart immediately
|
||||
});
|
||||
|
||||
// Wait for verifyToken to complete and populate window.cart, then process order
|
||||
this.waitForVerifyTokenAndProcessOrder();
|
||||
}
|
||||
|
||||
waitForVerifyTokenAndProcessOrder() {
|
||||
// Check if window.cart is already populated (verifyToken already completed)
|
||||
if (Array.isArray(window.cart) && window.cart.length > 0) {
|
||||
this.processStripeOrderWithCart(window.cart);
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for cart event which is dispatched after verifyToken completes
|
||||
this.verifyTokenHandler = () => {
|
||||
if (Array.isArray(window.cart) && window.cart.length > 0) {
|
||||
this.processStripeOrderWithCart([...window.cart]); // Copy the cart
|
||||
|
||||
// Clear window.cart after copying
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
} else {
|
||||
this.setState({
|
||||
completionError: "Cart is empty. Please add items to your cart before placing an order."
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up listener
|
||||
if (this.verifyTokenHandler) {
|
||||
window.removeEventListener('cart', this.verifyTokenHandler);
|
||||
this.verifyTokenHandler = null;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('cart', this.verifyTokenHandler);
|
||||
|
||||
// Set up a timeout as fallback (in case verifyToken fails)
|
||||
this.verifyTokenTimeout = setTimeout(() => {
|
||||
if (Array.isArray(window.cart) && window.cart.length > 0) {
|
||||
this.processStripeOrderWithCart([...window.cart]);
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
} else {
|
||||
this.setState({
|
||||
completionError: "Unable to load cart data. Please refresh the page and try again."
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (this.verifyTokenHandler) {
|
||||
window.removeEventListener('cart', this.verifyTokenHandler);
|
||||
this.verifyTokenHandler = null;
|
||||
}
|
||||
}, 5000); // 5 second timeout
|
||||
}
|
||||
|
||||
processStripeOrderWithCart(cartItems) {
|
||||
// Clear timeout if it exists
|
||||
if (this.verifyTokenTimeout) {
|
||||
clearTimeout(this.verifyTokenTimeout);
|
||||
this.verifyTokenTimeout = null;
|
||||
}
|
||||
|
||||
// Store cart items in state and process order
|
||||
this.setState({
|
||||
originalCartItems: cartItems
|
||||
}, () => {
|
||||
this.processStripeOrder();
|
||||
});
|
||||
}
|
||||
|
||||
processStripeOrder() {
|
||||
// If no original cart items, don't process
|
||||
if (!this.getState().originalCartItems || this.getState().originalCartItems.length === 0) {
|
||||
this.setState({ completionError: "Cart is empty. Please add items to your cart before placing an order." });
|
||||
return;
|
||||
}
|
||||
|
||||
// If socket is ready, process immediately
|
||||
const context = this.getContext();
|
||||
if (context && context.connected) {
|
||||
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
|
||||
if (isAuthenticated) {
|
||||
this.sendStripeOrder();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for socket to be ready
|
||||
this.socketHandler = () => {
|
||||
const context = this.getContext();
|
||||
if (context && context.connected) {
|
||||
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
|
||||
const state = this.getState();
|
||||
|
||||
if (isAuthenticated && state.showPaymentConfirmation && !state.isCompletingOrder) {
|
||||
this.sendStripeOrder();
|
||||
}
|
||||
}
|
||||
// Clean up
|
||||
if (this.socketHandler) {
|
||||
window.removeEventListener('cart', this.socketHandler);
|
||||
this.socketHandler = null;
|
||||
}
|
||||
};
|
||||
window.addEventListener('cart', this.socketHandler);
|
||||
}
|
||||
|
||||
sendStripeOrder() {
|
||||
const state = this.getState();
|
||||
|
||||
// Don't process if already processing or completed
|
||||
if (state.isCompletingOrder || state.orderCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isCompletingOrder: true, completionError: null });
|
||||
|
||||
const {
|
||||
deliveryMethod,
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
originalCartItems,
|
||||
note,
|
||||
saveAddressForFuture,
|
||||
} = state;
|
||||
|
||||
const deliveryCost = this.getDeliveryCost();
|
||||
|
||||
const orderData = {
|
||||
items: originalCartItems,
|
||||
invoiceAddress,
|
||||
deliveryAddress: useSameAddress ? invoiceAddress : deliveryAddress,
|
||||
deliveryMethod,
|
||||
paymentMethod: "stripe",
|
||||
deliveryCost,
|
||||
note,
|
||||
domain: window.location.origin,
|
||||
stripeData: this.paymentCompletionData ? {
|
||||
paymentIntent: this.paymentCompletionData.paymentIntent,
|
||||
paymentIntentClientSecret: this.paymentCompletionData.paymentIntentClientSecret,
|
||||
redirectStatus: this.paymentCompletionData.redirectStatus,
|
||||
} : null,
|
||||
saveAddressForFuture,
|
||||
};
|
||||
|
||||
// Emit stripe order to backend via socket.io
|
||||
const context = this.getContext();
|
||||
context.emit("issueStripeOrder", orderData, (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
orderCompleted: true,
|
||||
completionError: null,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: response.error || "Failed to complete order. Please try again.",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process regular (non-Stripe) orders
|
||||
processRegularOrder(orderData) {
|
||||
const context = this.getContext();
|
||||
if (context) {
|
||||
context.emit("issueOrder", orderData, (response) => {
|
||||
if (response.success) {
|
||||
// Clear the cart
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
|
||||
// Reset state and navigate to orders tab
|
||||
this.setState({
|
||||
isCheckingOut: false,
|
||||
cartItems: [],
|
||||
isCompletingOrder: false,
|
||||
completionError: null,
|
||||
});
|
||||
|
||||
// Call success callback if provided
|
||||
if (this.onOrderSuccess) {
|
||||
this.onOrderSuccess();
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: response.error || "Failed to complete order. Please try again.",
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error("Socket context not available");
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: "Cannot connect to server. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create Stripe payment intent
|
||||
createStripeIntent(totalAmount, loadStripeComponent) {
|
||||
const context = this.getContext();
|
||||
if (context) {
|
||||
context.emit(
|
||||
"createStripeIntent",
|
||||
{ amount: totalAmount },
|
||||
(response) => {
|
||||
if (response.success) {
|
||||
loadStripeComponent(response.client_secret);
|
||||
} else {
|
||||
console.error("Error:", response.error);
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: response.error || "Failed to create Stripe payment intent. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.error("Socket context not available");
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: "Cannot connect to server. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate delivery cost
|
||||
getDeliveryCost() {
|
||||
const { deliveryMethod, paymentMethod } = this.getState();
|
||||
let cost = 0;
|
||||
|
||||
switch (deliveryMethod) {
|
||||
case "DHL":
|
||||
cost = 6.99;
|
||||
break;
|
||||
case "DPD":
|
||||
cost = 4.9;
|
||||
break;
|
||||
case "Sperrgut":
|
||||
cost = 28.99;
|
||||
break;
|
||||
case "Abholung":
|
||||
cost = 0;
|
||||
break;
|
||||
default:
|
||||
cost = 6.99;
|
||||
}
|
||||
|
||||
// Add onDelivery surcharge if selected
|
||||
if (paymentMethod === "onDelivery") {
|
||||
cost += 8.99;
|
||||
}
|
||||
|
||||
return cost;
|
||||
}
|
||||
|
||||
// Helper method to get current state (to be overridden by component)
|
||||
getState() {
|
||||
throw new Error("getState method must be implemented by the component");
|
||||
}
|
||||
|
||||
// Set callback for order success
|
||||
setOrderSuccessCallback(callback) {
|
||||
this.onOrderSuccess = callback;
|
||||
}
|
||||
}
|
||||
|
||||
export default OrderProcessingService;
|
||||
106
src/components/profile/OrderSummary.js
Normal file
106
src/components/profile/OrderSummary.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
|
||||
const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
||||
const currencyFormatter = new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
});
|
||||
|
||||
// Calculate VAT breakdown for cart items (similar to CartDropdown)
|
||||
const cartVatCalculations = cartItems.reduce((acc, item) => {
|
||||
const totalItemPrice = item.price * item.quantity;
|
||||
const netPrice = totalItemPrice / (1 + item.vat / 100);
|
||||
const vatAmount = totalItemPrice - netPrice;
|
||||
|
||||
acc.totalGross += totalItemPrice;
|
||||
acc.totalNet += netPrice;
|
||||
|
||||
if (item.vat === 7) {
|
||||
acc.vat7 += vatAmount;
|
||||
} else if (item.vat === 19) {
|
||||
acc.vat19 += vatAmount;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
|
||||
|
||||
// Calculate shipping VAT (19% VAT for shipping costs)
|
||||
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
|
||||
const shippingVat = deliveryCost - shippingNetPrice;
|
||||
|
||||
// Combine totals - add shipping VAT to the 19% VAT total
|
||||
const totalVat7 = cartVatCalculations.vat7;
|
||||
const totalVat19 = cartVatCalculations.vat19 + shippingVat;
|
||||
const totalGross = cartVatCalculations.totalGross + deliveryCost;
|
||||
|
||||
return (
|
||||
<Box sx={{ my: 3, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Bestellübersicht
|
||||
</Typography>
|
||||
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Waren (netto):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(cartVatCalculations.totalNet)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>Versandkosten (netto):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(shippingNetPrice)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{totalVat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat7)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{totalVat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat19)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{currencyFormatter.format(cartVatCalculations.totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Versandkosten:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{currencyFormatter.format(deliveryCost)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
|
||||
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>Gesamtsumme:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
||||
{currencyFormatter.format(totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderSummary;
|
||||
246
src/components/profile/OrdersTab.js
Normal file
246
src/components/profile/OrdersTab.js
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Alert,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
import OrderDetailsDialog from "./OrderDetailsDialog.js";
|
||||
|
||||
// Constants
|
||||
const statusTranslations = {
|
||||
new: "in Bearbeitung",
|
||||
pending: "Neu",
|
||||
processing: "in Bearbeitung",
|
||||
cancelled: "Storniert",
|
||||
shipped: "Verschickt",
|
||||
delivered: "Geliefert",
|
||||
};
|
||||
|
||||
const statusEmojis = {
|
||||
"in Bearbeitung": "⚙️",
|
||||
pending: "⏳",
|
||||
processing: "🔄",
|
||||
cancelled: "❌",
|
||||
Verschickt: "🚚",
|
||||
Geliefert: "✅",
|
||||
Storniert: "❌",
|
||||
Retoure: "↩️",
|
||||
"Teil Retoure": "↪️",
|
||||
"Teil geliefert": "⚡",
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
"in Bearbeitung": "#ed6c02", // orange
|
||||
pending: "#ff9800", // orange for pending
|
||||
processing: "#2196f3", // blue for processing
|
||||
cancelled: "#d32f2f", // red for cancelled
|
||||
Verschickt: "#2e7d32", // green
|
||||
Geliefert: "#2e7d32", // green
|
||||
Storniert: "#d32f2f", // red
|
||||
Retoure: "#9c27b0", // purple
|
||||
"Teil Retoure": "#9c27b0", // purple
|
||||
"Teil geliefert": "#009688", // teal
|
||||
};
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
});
|
||||
|
||||
// Orders Tab Content Component
|
||||
const OrdersTab = ({ orderIdFromHash }) => {
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
|
||||
|
||||
const socket = useContext(SocketContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleViewDetails = useCallback(
|
||||
(orderId) => {
|
||||
const orderToView = orders.find((order) => order.orderId === orderId);
|
||||
if (orderToView) {
|
||||
setSelectedOrder(orderToView);
|
||||
setIsDetailsDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[orders]
|
||||
);
|
||||
|
||||
const fetchOrders = useCallback(() => {
|
||||
if (socket && socket.connected) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
socket.emit("getOrders", (response) => {
|
||||
if (response.success) {
|
||||
setOrders(response.orders);
|
||||
} else {
|
||||
setError(response.error || "Failed to fetch orders.");
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
console.log("Socket not connected yet, waiting for connection to fetch orders");
|
||||
setLoading(false); // Stop loading when socket is not connected
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
// Monitor socket connection changes
|
||||
useEffect(() => {
|
||||
if (socket && socket.connected && orders.length === 0) {
|
||||
// Socket just connected and we don't have orders yet, fetch them
|
||||
fetchOrders();
|
||||
}
|
||||
}, [socket, socket?.connected, orders.length, fetchOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (orderIdFromHash && orders.length > 0) {
|
||||
handleViewDetails(orderIdFromHash);
|
||||
}
|
||||
}, [orderIdFromHash, orders, handleViewDetails]);
|
||||
|
||||
const getStatusDisplay = (status) => {
|
||||
return statusTranslations[status] || status;
|
||||
};
|
||||
|
||||
const getStatusEmoji = (status) => {
|
||||
return statusEmojis[status] || "❓";
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
return statusColors[status] || "#757575"; // default gray
|
||||
};
|
||||
|
||||
const handleCloseDetailsDialog = () => {
|
||||
setIsDetailsDialogOpen(false);
|
||||
setSelectedOrder(null);
|
||||
navigate("/profile", { replace: true });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ p: 3, display: "flex", justifyContent: "center" }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{orders.length > 0 ? (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bestellnummer</TableCell>
|
||||
<TableCell>Datum</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Artikel</TableCell>
|
||||
<TableCell align="right">Summe</TableCell>
|
||||
<TableCell align="center">Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{orders.map((order) => {
|
||||
const displayStatus = getStatusDisplay(order.status);
|
||||
const subtotal = order.items.reduce(
|
||||
(acc, item) => acc + item.price * item.quantity_ordered,
|
||||
0
|
||||
);
|
||||
const total = subtotal + order.delivery_cost;
|
||||
return (
|
||||
<TableRow key={order.orderId} hover>
|
||||
<TableCell>{order.orderId}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(order.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
color: getStatusColor(displayStatus),
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "1.2rem" }}>
|
||||
{getStatusEmoji(displayStatus)}
|
||||
</span>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="span"
|
||||
sx={{ fontWeight: "medium" }}
|
||||
>
|
||||
{displayStatus}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{order.items.reduce(
|
||||
(acc, item) => acc + item.quantity_ordered,
|
||||
0
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(total)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="Details anzeigen">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleViewDetails(order.orderId)}
|
||||
>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
Sie haben noch keine Bestellungen aufgegeben.
|
||||
</Alert>
|
||||
)}
|
||||
<OrderDetailsDialog
|
||||
open={isDetailsDialogOpen}
|
||||
onClose={handleCloseDetailsDialog}
|
||||
order={selectedOrder}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrdersTab;
|
||||
97
src/components/profile/PaymentConfirmationDialog.js
Normal file
97
src/components/profile/PaymentConfirmationDialog.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Typography, Button } from "@mui/material";
|
||||
|
||||
class PaymentConfirmationDialog extends Component {
|
||||
render() {
|
||||
const {
|
||||
paymentCompletionData,
|
||||
isCompletingOrder,
|
||||
completionError,
|
||||
orderCompleted,
|
||||
onContinueShopping,
|
||||
onViewOrders,
|
||||
} = this.props;
|
||||
|
||||
if (!paymentCompletionData) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
mb: 3,
|
||||
p: 3,
|
||||
border: '2px solid',
|
||||
borderColor: paymentCompletionData.isSuccessful ? '#2e7d32' : '#d32f2f',
|
||||
borderRadius: 2,
|
||||
bgcolor: paymentCompletionData.isSuccessful ? '#e8f5e8' : '#ffebee'
|
||||
}}>
|
||||
<Typography variant="h5" sx={{
|
||||
mb: 2,
|
||||
color: paymentCompletionData.isSuccessful ? '#2e7d32' : '#d32f2f',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{paymentCompletionData.isSuccessful ? 'Zahlung erfolgreich!' : 'Zahlung fehlgeschlagen'}
|
||||
</Typography>
|
||||
|
||||
{paymentCompletionData.isSuccessful ? (
|
||||
<>
|
||||
{orderCompleted ? (
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
|
||||
🎉 Ihre Bestellung wurde erfolgreich abgeschlossen! Sie können jetzt Ihre Bestellungen einsehen.
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
|
||||
Ihre Zahlung wurde erfolgreich verarbeitet. Die Bestellung wird automatisch abgeschlossen.
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#d32f2f' }}>
|
||||
Ihre Zahlung konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut oder wählen Sie eine andere Zahlungsmethode.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{isCompletingOrder && (
|
||||
<Typography variant="body2" sx={{ mt: 2, color: '#2e7d32', p: 2, bgcolor: '#e8f5e8', borderRadius: 1 }}>
|
||||
Bestellung wird abgeschlossen...
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{completionError && (
|
||||
<Typography variant="body2" sx={{ mt: 2, color: '#d32f2f', p: 2, bgcolor: '#ffcdd2', borderRadius: 1 }}>
|
||||
{completionError}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{orderCompleted && (
|
||||
<Box sx={{ mt: 3, display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={onContinueShopping}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
borderColor: '#2e7d32',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(46, 125, 50, 0.04)',
|
||||
borderColor: '#1b5e20'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Weiter einkaufen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onViewOrders}
|
||||
variant="contained"
|
||||
sx={{
|
||||
bgcolor: '#2e7d32',
|
||||
'&:hover': { bgcolor: '#1b5e20' }
|
||||
}}
|
||||
>
|
||||
Zu meinen Bestellungen
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PaymentConfirmationDialog;
|
||||
178
src/components/profile/PaymentMethodSelector.js
Normal file
178
src/components/profile/PaymentMethodSelector.js
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useCallback } from "react";
|
||||
import { Box, Typography, Radio } from "@mui/material";
|
||||
|
||||
const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeliveryMethodChange, cartItems = [], deliveryCost = 0 }) => {
|
||||
|
||||
// Calculate total amount
|
||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||
const totalAmount = subtotal + deliveryCost;
|
||||
|
||||
// Handle payment method changes with automatic delivery method adjustment
|
||||
const handlePaymentMethodChange = useCallback((event) => {
|
||||
const selectedPaymentMethod = event.target.value;
|
||||
|
||||
// If "Zahlung in der Filiale" is selected, force delivery method to "Abholung"
|
||||
if (selectedPaymentMethod === "cash" && deliveryMethod !== "Abholung") {
|
||||
if (onDeliveryMethodChange) {
|
||||
onDeliveryMethodChange({ target: { value: "Abholung" } });
|
||||
}
|
||||
}
|
||||
|
||||
onChange(event);
|
||||
}, [deliveryMethod, onDeliveryMethodChange, onChange]);
|
||||
|
||||
// Handle delivery method changes - auto-switch to stripe when DHL/DPD is selected
|
||||
useEffect(() => {
|
||||
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
|
||||
handlePaymentMethodChange({ target: { value: "stripe" } });
|
||||
}
|
||||
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
|
||||
|
||||
// Auto-switch to cash when total amount is 0
|
||||
useEffect(() => {
|
||||
if (totalAmount === 0 && paymentMethod !== "cash") {
|
||||
handlePaymentMethodChange({ target: { value: "cash" } });
|
||||
}
|
||||
}, [totalAmount, paymentMethod, handlePaymentMethodChange]);
|
||||
|
||||
const paymentOptions = [
|
||||
{
|
||||
id: "wire",
|
||||
name: "Überweisung",
|
||||
description: "Bezahlen Sie per Banküberweisung",
|
||||
disabled: totalAmount === 0,
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
name: "Karte oder Sofortüberweisung",
|
||||
description: totalAmount < 0.50 && totalAmount > 0
|
||||
? "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)"
|
||||
: "Bezahlen Sie per Karte oder Sofortüberweisung",
|
||||
disabled: totalAmount < 0.50 || (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung"),
|
||||
icons: [
|
||||
"/assets/images/giropay.png",
|
||||
"/assets/images/maestro.png",
|
||||
"/assets/images/mastercard.png",
|
||||
"/assets/images/visa_electron.png",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "onDelivery",
|
||||
name: "Nachnahme",
|
||||
description: "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
|
||||
disabled: totalAmount === 0 || deliveryMethod !== "DHL",
|
||||
icons: ["/assets/images/cash.png"],
|
||||
},
|
||||
{
|
||||
id: "cash",
|
||||
name: "Zahlung in der Filiale",
|
||||
description: "Bei Abholung bezahlen",
|
||||
disabled: false, // Always enabled
|
||||
icons: ["/assets/images/cash.png"],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Zahlungsart wählen
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{paymentOptions.map((option, index) => (
|
||||
<Box
|
||||
key={option.id}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
mb: index < paymentOptions.length - 1 ? 1 : 0,
|
||||
p: 1,
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: 1,
|
||||
cursor: option.disabled ? "not-allowed" : "pointer",
|
||||
opacity: option.disabled ? 0.6 : 1,
|
||||
transition: "all 0.2s ease-in-out",
|
||||
"&:hover": !option.disabled
|
||||
? {
|
||||
backgroundColor: "#f5f5f5",
|
||||
borderColor: "#2e7d32",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
}
|
||||
: {},
|
||||
...(paymentMethod === option.id &&
|
||||
!option.disabled && {
|
||||
backgroundColor: "#e8f5e8",
|
||||
borderColor: "#2e7d32",
|
||||
}),
|
||||
}}
|
||||
onClick={
|
||||
!option.disabled
|
||||
? () => handlePaymentMethodChange({ target: { value: option.id } })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Radio
|
||||
id={option.id}
|
||||
name="paymentMethod"
|
||||
value={option.id}
|
||||
checked={paymentMethod === option.id}
|
||||
onChange={handlePaymentMethodChange}
|
||||
disabled={option.disabled}
|
||||
sx={{ cursor: option.disabled ? "not-allowed" : "pointer" }}
|
||||
/>
|
||||
<Box sx={{ ml: 2, flex: 1 }}>
|
||||
<label
|
||||
htmlFor={option.id}
|
||||
style={{
|
||||
cursor: option.disabled ? "not-allowed" : "pointer",
|
||||
color: option.disabled ? "#999" : "inherit",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: option.disabled ? "#999" : "inherit" }}
|
||||
>
|
||||
{option.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ color: option.disabled ? "#ccc" : "text.secondary" }}
|
||||
>
|
||||
{option.description}
|
||||
</Typography>
|
||||
</label>
|
||||
</Box>
|
||||
{option.icons && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
ml: 2
|
||||
}}
|
||||
>
|
||||
{option.icons.map((iconPath, iconIndex) => (
|
||||
<img
|
||||
key={iconIndex}
|
||||
src={iconPath}
|
||||
alt={`Payment method ${iconIndex + 1}`}
|
||||
style={{
|
||||
height: "24px",
|
||||
width: "auto",
|
||||
opacity: option.disabled ? 0.5 : 1,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethodSelector;
|
||||
426
src/components/profile/SettingsTab.js
Normal file
426
src/components/profile/SettingsTab.js
Normal file
@@ -0,0 +1,426 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
IconButton,
|
||||
Snackbar
|
||||
} from '@mui/material';
|
||||
import { ContentCopy } from '@mui/icons-material';
|
||||
|
||||
class SettingsTab extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
password: '',
|
||||
newEmail: '',
|
||||
passwordError: '',
|
||||
passwordSuccess: '',
|
||||
emailError: '',
|
||||
emailSuccess: '',
|
||||
loading: false,
|
||||
// API Key management state
|
||||
hasApiKey: false,
|
||||
apiKey: '',
|
||||
apiKeyDisplay: '',
|
||||
apiKeyError: '',
|
||||
apiKeySuccess: '',
|
||||
loadingApiKey: false,
|
||||
copySnackbarOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Load user data
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
try {
|
||||
const user = JSON.parse(storedUser);
|
||||
this.setState({ newEmail: user.email || '' });
|
||||
|
||||
// Check if user has an API key
|
||||
this.props.socket.emit('isApiKey', (response) => {
|
||||
if (response.success && response.hasApiKey) {
|
||||
this.setState({
|
||||
hasApiKey: true,
|
||||
apiKeyDisplay: '************'
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading user data:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdatePassword = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Reset states
|
||||
this.setState({
|
||||
passwordError: '',
|
||||
passwordSuccess: ''
|
||||
});
|
||||
|
||||
// Validation
|
||||
if (!this.state.currentPassword || !this.state.newPassword || !this.state.confirmPassword) {
|
||||
this.setState({ passwordError: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.newPassword !== this.state.confirmPassword) {
|
||||
this.setState({ passwordError: 'Die neuen Passwörter stimmen nicht überein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.newPassword.length < 8) {
|
||||
this.setState({ passwordError: 'Das neue Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
// Call socket.io endpoint to update password
|
||||
this.props.socket.emit('updatePassword',
|
||||
{ oldPassword: this.state.currentPassword, newPassword: this.state.newPassword },
|
||||
(response) => {
|
||||
this.setState({ loading: false });
|
||||
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
passwordSuccess: 'Passwort erfolgreich aktualisiert',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
passwordError: response.message || 'Fehler beim Aktualisieren des Passworts'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleUpdateEmail = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Reset states
|
||||
this.setState({
|
||||
emailError: '',
|
||||
emailSuccess: ''
|
||||
});
|
||||
|
||||
// Validation
|
||||
if (!this.state.password || !this.state.newEmail) {
|
||||
this.setState({ emailError: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.state.newEmail)) {
|
||||
this.setState({ emailError: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
// Call socket.io endpoint to update email
|
||||
this.props.socket.emit('updateEmail',
|
||||
{ password: this.state.password, email: this.state.newEmail },
|
||||
(response) => {
|
||||
this.setState({ loading: false });
|
||||
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
emailSuccess: 'E-Mail-Adresse erfolgreich aktualisiert',
|
||||
password: ''
|
||||
});
|
||||
|
||||
// Update user in sessionStorage
|
||||
try {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
const user = JSON.parse(storedUser);
|
||||
user.email = this.state.newEmail;
|
||||
sessionStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user in sessionStorage:', error);
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
emailError: response.message || 'Fehler beim Aktualisieren der E-Mail-Adresse'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleGenerateApiKey = () => {
|
||||
this.setState({
|
||||
apiKeyError: '',
|
||||
apiKeySuccess: '',
|
||||
loadingApiKey: true
|
||||
});
|
||||
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (!storedUser) {
|
||||
this.setState({
|
||||
apiKeyError: 'Benutzer nicht gefunden',
|
||||
loadingApiKey: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = JSON.parse(storedUser);
|
||||
|
||||
this.props.socket.emit('createApiKey', user.id, (response) => {
|
||||
this.setState({ loadingApiKey: false });
|
||||
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
hasApiKey: true,
|
||||
apiKey: response.apiKey,
|
||||
apiKeyDisplay: response.apiKey,
|
||||
apiKeySuccess: response.message || 'API-Schlüssel erfolgreich generiert'
|
||||
});
|
||||
|
||||
// After 10 seconds, hide the actual key and show asterisks
|
||||
setTimeout(() => {
|
||||
this.setState({ apiKeyDisplay: '************' });
|
||||
}, 10000);
|
||||
} else {
|
||||
this.setState({
|
||||
apiKeyError: response.message || 'Fehler beim Generieren des API-Schlüssels'
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating API key:', error);
|
||||
this.setState({
|
||||
apiKeyError: 'Fehler beim Generieren des API-Schlüssels',
|
||||
loadingApiKey: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleCopyToClipboard = () => {
|
||||
navigator.clipboard.writeText(this.state.apiKey).then(() => {
|
||||
this.setState({ copySnackbarOpen: true });
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = this.state.apiKey;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
this.setState({ copySnackbarOpen: true });
|
||||
});
|
||||
};
|
||||
|
||||
handleCloseSnackbar = () => {
|
||||
this.setState({ copySnackbarOpen: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Paper sx={{ p: 3}}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
Passwort ändern
|
||||
</Typography>
|
||||
|
||||
{this.state.passwordError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.passwordError}</Alert>}
|
||||
{this.state.passwordSuccess && <Alert severity="success" sx={{ mb: 2 }}>{this.state.passwordSuccess}</Alert>}
|
||||
|
||||
<Box component="form" onSubmit={this.handleUpdatePassword}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Aktuelles Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.currentPassword}
|
||||
onChange={(e) => this.setState({ currentPassword: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Neues Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.newPassword}
|
||||
onChange={(e) => this.setState({ newPassword: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Neues Passwort bestätigen"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.confirmPassword}
|
||||
onChange={(e) => this.setState({ confirmPassword: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||
disabled={this.state.loading}
|
||||
>
|
||||
{this.state.loading ? <CircularProgress size={24} /> : 'Passwort aktualisieren'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
E-Mail-Adresse ändern
|
||||
</Typography>
|
||||
|
||||
{this.state.emailError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.emailError}</Alert>}
|
||||
{this.state.emailSuccess && <Alert severity="success" sx={{ mb: 2 }}>{this.state.emailSuccess}</Alert>}
|
||||
|
||||
<Box component="form" onSubmit={this.handleUpdateEmail}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.password}
|
||||
onChange={(e) => this.setState({ password: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Neue E-Mail-Adresse"
|
||||
type="email"
|
||||
fullWidth
|
||||
value={this.state.newEmail}
|
||||
onChange={(e) => this.setState({ newEmail: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||
disabled={this.state.loading}
|
||||
>
|
||||
{this.state.loading ? <CircularProgress size={24} /> : 'E-Mail aktualisieren'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
API-Schlüssel
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Verwenden Sie Ihren API-Schlüssel für die Integration mit externen Anwendungen.
|
||||
</Typography>
|
||||
|
||||
{this.state.apiKeyError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.apiKeyError}</Alert>}
|
||||
{this.state.apiKeySuccess && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{this.state.apiKeySuccess}
|
||||
{this.state.apiKey && this.state.apiKeyDisplay !== '************' && (
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
Speichern Sie diesen Schlüssel sicher. Er wird aus Sicherheitsgründen in 10 Sekunden ausgeblendet.
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
API-Dokumentation: {' '}
|
||||
<a
|
||||
href={`${window.location.protocol}//${window.location.host}/api/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#2e7d32' }}
|
||||
>
|
||||
{`${window.location.protocol}//${window.location.host}/api/`}
|
||||
</a>
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 2 }}>
|
||||
<TextField
|
||||
label="API-Schlüssel"
|
||||
value={this.state.apiKeyDisplay}
|
||||
disabled
|
||||
fullWidth
|
||||
sx={{
|
||||
'& .MuiInputBase-input.Mui-disabled': {
|
||||
WebkitTextFillColor: this.state.apiKeyDisplay === '************' ? '#666' : '#000',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{this.state.apiKeyDisplay !== '************' && this.state.apiKey && (
|
||||
<IconButton
|
||||
onClick={this.handleCopyToClipboard}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
'&:hover': { bgcolor: 'rgba(46, 125, 50, 0.1)' }
|
||||
}}
|
||||
title="In Zwischenablage kopieren"
|
||||
>
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={this.handleGenerateApiKey}
|
||||
disabled={this.state.loadingApiKey}
|
||||
sx={{
|
||||
minWidth: 120,
|
||||
bgcolor: '#2e7d32',
|
||||
'&:hover': { bgcolor: '#1b5e20' }
|
||||
}}
|
||||
>
|
||||
{this.state.loadingApiKey ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
this.state.hasApiKey ? 'Regenerieren' : 'Generieren'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Snackbar
|
||||
open={this.state.copySnackbarOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={this.handleCloseSnackbar}
|
||||
message="API-Schlüssel in Zwischenablage kopiert"
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SettingsTab;
|
||||
20
src/components/withRouter.js
Normal file
20
src/components/withRouter.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useNavigate, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
export function withRouter(Component) {
|
||||
function ComponentWithRouterProp(props) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
navigate={navigate}
|
||||
location={location}
|
||||
params={params}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return ComponentWithRouterProp;
|
||||
}
|
||||
38
src/config.js
Normal file
38
src/config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const config = {
|
||||
baseUrl: "https://seedheads.de",
|
||||
apiBaseUrl: "",
|
||||
googleClientId: "928121624463-jbgfdlgem22scs1k9c87ucg4ffvaik6o.apps.googleusercontent.com",
|
||||
stripePublishableKey: "pk_test_51R7lltRtpe3h1vwJzIrDb5bcEigTLBHrtqj9SiPX7FOEATSuD6oJmKc8xpNp49ShpGJZb2GShHIUqj4zlSIz4olj00ipOuOAnu",
|
||||
|
||||
// SEO and Business Information
|
||||
siteName: "SeedHeads.de",
|
||||
brandName: "SeedHeads",
|
||||
currency: "EUR",
|
||||
language: "de-DE",
|
||||
country: "DE",
|
||||
|
||||
// Shop Descriptions
|
||||
descriptions: {
|
||||
short: "SeedHeads - Online-Shop für Samen, Pflanzen und Gartenbedarf",
|
||||
long: "SeedHeads - Ihr Online-Shop für hochwertige Samen, Pflanzen und Gartenbedarf. Entdecken Sie unser großes Sortiment an Saatgut, Pflanzen und Gartenzubehör für Ihren grünen Daumen."
|
||||
},
|
||||
|
||||
// Keywords
|
||||
keywords: "Samen, Pflanzen, Gartenbedarf, Saatgut, Online-Shop, SeedHeads, Garten, Pflanzen kaufen",
|
||||
|
||||
// Shipping
|
||||
shipping: {
|
||||
defaultCost: "4.99 EUR",
|
||||
defaultService: "Standard"
|
||||
},
|
||||
|
||||
// Images
|
||||
images: {
|
||||
logo: "/assets/images/sh.png",
|
||||
placeholder: "/assets/images/nopicture.jpg"
|
||||
},
|
||||
|
||||
// Add other configuration values here as needed
|
||||
};
|
||||
|
||||
export default config;
|
||||
6
src/contexts/GoogleAuthContext.js
Normal file
6
src/contexts/GoogleAuthContext.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import React, { createContext } from 'react';
|
||||
|
||||
// Create a new context for Google Auth
|
||||
const GoogleAuthContext = createContext(null);
|
||||
|
||||
export default GoogleAuthContext;
|
||||
7
src/contexts/SocketContext.js
Normal file
7
src/contexts/SocketContext.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
// Create a new context for Socket.IO
|
||||
const SocketContext = React.createContext(null);
|
||||
|
||||
export const SocketConsumer = SocketContext.Consumer;
|
||||
export default SocketContext;
|
||||
269
src/data/configuratorData.js
Normal file
269
src/data/configuratorData.js
Normal file
@@ -0,0 +1,269 @@
|
||||
// @note Dummy data for grow tent configurator - no backend calls
|
||||
export const tentShapes = [
|
||||
{
|
||||
id: '60x60',
|
||||
name: '60x60cm',
|
||||
description: 'Kompakt - ideal für kleine Räume',
|
||||
footprint: '60x60',
|
||||
minPlants: 1,
|
||||
maxPlants: 2,
|
||||
visualWidth: 60,
|
||||
visualDepth: 60
|
||||
},
|
||||
{
|
||||
id: '80x80',
|
||||
name: '80x80cm',
|
||||
description: 'Mittel - perfekte Balance',
|
||||
footprint: '80x80',
|
||||
minPlants: 2,
|
||||
maxPlants: 4,
|
||||
visualWidth: 80,
|
||||
visualDepth: 80
|
||||
},
|
||||
{
|
||||
id: '100x100',
|
||||
name: '100x100cm',
|
||||
description: 'Groß - für erfahrene Grower',
|
||||
footprint: '100x100',
|
||||
minPlants: 4,
|
||||
maxPlants: 6,
|
||||
visualWidth: 100,
|
||||
visualDepth: 100
|
||||
},
|
||||
{
|
||||
id: '120x60',
|
||||
name: '120x60cm',
|
||||
description: 'Rechteckig - maximale Raumnutzung',
|
||||
footprint: '120x60',
|
||||
minPlants: 3,
|
||||
maxPlants: 6,
|
||||
visualWidth: 120,
|
||||
visualDepth: 60
|
||||
}
|
||||
];
|
||||
|
||||
export const tentSizes = [
|
||||
// 60x60 tents
|
||||
{
|
||||
id: 'tent_60x60x140',
|
||||
name: 'Basic 140cm',
|
||||
description: 'Einsteigermodell',
|
||||
price: 89.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '60x60x140cm',
|
||||
coverage: '1-2 Pflanzen',
|
||||
shapeId: '60x60',
|
||||
height: 140
|
||||
},
|
||||
{
|
||||
id: 'tent_60x60x160',
|
||||
name: 'Premium 160cm',
|
||||
description: 'Mehr Höhe für größere Pflanzen',
|
||||
price: 109.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '60x60x160cm',
|
||||
coverage: '1-2 Pflanzen',
|
||||
shapeId: '60x60',
|
||||
height: 160
|
||||
},
|
||||
// 80x80 tents
|
||||
{
|
||||
id: 'tent_80x80x160',
|
||||
name: 'Standard 160cm',
|
||||
description: 'Beliebtes Mittelklasse-Modell',
|
||||
price: 129.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '80x80x160cm',
|
||||
coverage: '2-4 Pflanzen',
|
||||
shapeId: '80x80',
|
||||
height: 160
|
||||
},
|
||||
{
|
||||
id: 'tent_80x80x180',
|
||||
name: 'Pro 180cm',
|
||||
description: 'Extra Höhe für optimales Wachstum',
|
||||
price: 149.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '80x80x180cm',
|
||||
coverage: '2-4 Pflanzen',
|
||||
shapeId: '80x80',
|
||||
height: 180
|
||||
},
|
||||
// 100x100 tents
|
||||
{
|
||||
id: 'tent_100x100x180',
|
||||
name: 'Professional 180cm',
|
||||
description: 'Für anspruchsvolle Projekte',
|
||||
price: 189.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '100x100x180cm',
|
||||
coverage: '4-6 Pflanzen',
|
||||
shapeId: '100x100',
|
||||
height: 180
|
||||
},
|
||||
{
|
||||
id: 'tent_100x100x200',
|
||||
name: 'Expert 200cm',
|
||||
description: 'Maximum an Wuchshöhe',
|
||||
price: 219.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '100x100x200cm',
|
||||
coverage: '4-6 Pflanzen',
|
||||
shapeId: '100x100',
|
||||
height: 200
|
||||
},
|
||||
// 120x60 tents
|
||||
{
|
||||
id: 'tent_120x60x160',
|
||||
name: 'Rectangular 160cm',
|
||||
description: 'Platzsparend und effizient',
|
||||
price: 139.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '120x60x160cm',
|
||||
coverage: '3-6 Pflanzen',
|
||||
shapeId: '120x60',
|
||||
height: 160
|
||||
},
|
||||
{
|
||||
id: 'tent_120x60x180',
|
||||
name: 'Rectangular Pro 180cm',
|
||||
description: 'Optimale Raumausnutzung',
|
||||
price: 169.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
dimensions: '120x60x180cm',
|
||||
coverage: '3-6 Pflanzen',
|
||||
shapeId: '120x60',
|
||||
height: 180
|
||||
}
|
||||
];
|
||||
|
||||
export const lightTypes = [
|
||||
{
|
||||
id: 'led_quantum_board',
|
||||
name: 'LED Quantum Board',
|
||||
description: 'Energieeffizient, geringe Wärmeentwicklung',
|
||||
price: 159.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
wattage: '240W',
|
||||
coverage: 'Bis 100x100cm',
|
||||
spectrum: 'Vollspektrum',
|
||||
efficiency: 'Sehr hoch'
|
||||
},
|
||||
{
|
||||
id: 'led_cob',
|
||||
name: 'LED COB',
|
||||
description: 'Hochintensive COB-LEDs',
|
||||
price: 199.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
wattage: '300W',
|
||||
coverage: 'Bis 120x120cm',
|
||||
spectrum: 'Vollspektrum',
|
||||
efficiency: 'Hoch'
|
||||
},
|
||||
{
|
||||
id: 'hps_400w',
|
||||
name: 'HPS 400W',
|
||||
description: 'Bewährte Natriumdampflampe',
|
||||
price: 89.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
wattage: '400W',
|
||||
coverage: 'Bis 80x80cm',
|
||||
spectrum: 'Blüte-optimiert',
|
||||
efficiency: 'Mittel'
|
||||
},
|
||||
{
|
||||
id: 'cmh_315w',
|
||||
name: 'CMH 315W',
|
||||
description: 'Keramik-Metallhalogenid',
|
||||
price: 129.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
wattage: '315W',
|
||||
coverage: 'Bis 90x90cm',
|
||||
spectrum: 'Natürlich',
|
||||
efficiency: 'Hoch'
|
||||
}
|
||||
];
|
||||
|
||||
export const ventilationTypes = [
|
||||
{
|
||||
id: 'basic_exhaust',
|
||||
name: 'Basic Abluft-Set',
|
||||
description: 'Lüfter + Aktivkohlefilter',
|
||||
price: 79.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
airflow: '187 m³/h',
|
||||
noiseLevel: '35 dB',
|
||||
includes: ['Rohrventilator', 'Aktivkohlefilter', 'Aluflexrohr']
|
||||
},
|
||||
{
|
||||
id: 'premium_ventilation',
|
||||
name: 'Premium Klima-Set',
|
||||
description: 'Komplette Klimakontrolle',
|
||||
price: 159.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
airflow: '280 m³/h',
|
||||
noiseLevel: '28 dB',
|
||||
includes: ['EC-Lüfter', 'Aktivkohlefilter', 'Thermostat', 'Feuchtigkeitsmesser']
|
||||
},
|
||||
{
|
||||
id: 'pro_climate',
|
||||
name: 'Profi Klima-System',
|
||||
description: 'Automatisierte Klimasteuerung',
|
||||
price: 299.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
airflow: '420 m³/h',
|
||||
noiseLevel: '25 dB',
|
||||
includes: ['Digitaler Controller', 'EC-Lüfter', 'Aktivkohlefilter', 'Zu-/Abluft']
|
||||
}
|
||||
];
|
||||
|
||||
export const extras = [
|
||||
{
|
||||
id: 'ph_tester',
|
||||
name: 'pH-Messgerät',
|
||||
description: 'Digitales pH-Meter',
|
||||
price: 29.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Messung'
|
||||
},
|
||||
{
|
||||
id: 'nutrients_starter',
|
||||
name: 'Dünger Starter-Set',
|
||||
description: 'Komplettes Nährstoff-Set',
|
||||
price: 39.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Nährstoffe'
|
||||
},
|
||||
{
|
||||
id: 'grow_pots',
|
||||
name: 'Grow-Töpfe Set (5x)',
|
||||
description: '5x Stofftöpfe 11L',
|
||||
price: 24.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Töpfe'
|
||||
},
|
||||
{
|
||||
id: 'timer_socket',
|
||||
name: 'Zeitschaltuhr',
|
||||
description: 'Digitale Zeitschaltuhr',
|
||||
price: 19.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Steuerung'
|
||||
},
|
||||
{
|
||||
id: 'thermometer',
|
||||
name: 'Thermo-Hygrometer',
|
||||
description: 'Min/Max Temperatur & Luftfeuchtigkeit',
|
||||
price: 14.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Messung'
|
||||
},
|
||||
{
|
||||
id: 'pruning_shears',
|
||||
name: 'Gartenschere',
|
||||
description: 'Präzisions-Gartenschere',
|
||||
price: 16.99,
|
||||
image: '/assets/images/nopicture.jpg',
|
||||
category: 'Werkzeug'
|
||||
}
|
||||
];
|
||||
59
src/index.css
Normal file
59
src/index.css
Normal file
@@ -0,0 +1,59 @@
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url('../public/assets/fonts/roboto/Roboto-Light.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('../public/assets/fonts/roboto/Roboto-Regular.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('../public/assets/fonts/roboto/Roboto-Medium.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('../public/assets/fonts/roboto/Roboto-Bold.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SwashingtonCP';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: url('../public/assets/fonts/SwashingtonCP.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-y: scroll; /* Always show vertical scrollbar */
|
||||
}
|
||||
|
||||
/* Prevent Material-UI from changing scrollbar when modals open */
|
||||
body.MuiModal-open {
|
||||
overflow-y: scroll !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
18
src/index.js
Normal file
18
src/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.js";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
// Create a wrapper component with our class-based GoogleAuthProvider
|
||||
// This avoids the "Invalid hook call" error from GoogleOAuthProvider
|
||||
const AppWithProviders = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||
root.render(<AppWithProviders />);
|
||||
176
src/pages/AGB.js
Normal file
176
src/pages/AGB.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
import LegalPage from './LegalPage.js';
|
||||
|
||||
const AGB = () => {
|
||||
const content = (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Liefer- & Versandbedingungen
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
1. Der Versand dauert zwischen 1 und 7 Tagen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
2. Die Ware bleibt bis zur vollständigen Bezahlung Eigentum von Growheads.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
3. Bei der Vermutung, dass die Ware durch den Transport beschädigt wurde oder Ware fehlt, ist die Versandverpackung zur Ansicht durch einen Gutachter aufzubewahren. Eine Beschädigung der Verpackung ist durch den Transporteur nach Art und Umfang auf dem Lieferschein zu bestätigen. Versandschäden müssen sofort schriftlich per Fax, Email oder Post an Growheads gemeldet werden. Dafür müssen Fotos von der beschädigten Ware sowie von dem beschädigten Versandkarton samt Adressaufkleber erstellt werden. Der beschädigte Versandkarton ist auch aufzubewahren. Diese werden benötigt um den Schaden der Transportfirma in Rechnung zu stellen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
4. Bei der Rücksendung mangelhafter Ware hat der Kunde Sorge zu tragen, dass die Ware ordnungsgemäß verpackt wird.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
5. Alle Rücksendungen sind vorher bei Growheads anzumelden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
6. Für das Zusenden von Gegenständen an uns trägt der Kunde die Gefahr, soweit es sich dabei nicht um die Rücksendung mangelhafter Ware handelt.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
7. Growheads ist berechtigt, die Ware durch die Deutsche Post/GLS oder einen Spediteur seiner Wahl, abholen zu lassen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
8. Die Portokosten werden nach Gewicht berechnet. Eventuelle Preiserhöhungen der Transportunternehmen (Maut, Treibstoffzuschläge) behält sich Growheads vor.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
9. Unsere Pakete werden in der Regel versendet mit: GLS, DHL & der Deutschen Post AG.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
10. Bei besonders schweren oder sperrigen Artikeln behalten wir uns Zuschläge auf die Lieferkosten vor. In der Regel sind diese Zuschläge in der Preisliste aufgeführt.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
11. Es kann per Vorkasse an die angegebene Bankverbindung überwiesen werden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
12. Kommt es zu einer Lieferverzögerung, die von uns zu vertreten ist, so ist die Dauer der Nachfrist, die der Käufer zu setzen berechtigt ist, auf zwei Wochen festgelegt. Die Frist beginnt mit Eingang der Nachfristsetzung bei Growheads.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
13. Offensichtliche Mängel der Ware ist sofort nach Lieferung schriftlich anzuzeigen. Kommt der Kunde dieser Verpflichtung nicht nach, so sind Gewährleistungsansprüche wegen offensichtlicher Mängel ausgeschlossen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
14. Rügt der Kunde einen Mangel, so hat er die mangelhafte Ware mit einer möglichst genauen Fehlerbeschreibung an uns zurück zu senden. Der Sendung ist eine Kopie unserer Rechnung beizulegen. Die Ware ist in der Originalverpackung zurück zu senden oder aber in einer Verpackung, welche die Ware entsprechend der Originalverpackung schützt, so dass Schäden auf dem Rücktransport vermieden werden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Beratung und Haftung
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
1. Anwendungstechnische Beratung geben wir nach bestem Wissen aufgrund des Standes unserer Erfahrung und Kenntnisse.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
2. Für die Beachtung gesetzlicher Vorschriften bei Lagerung, Weitertransport und Verwendung unserer Waren ist der Käufer verantwortlich.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Zahlungsbedingungen
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
1. Die Ware bleibt bis zur vollständigen Bezahlung Eigentum von Growheads.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
2. Rechnungen werden per Vorkasse auf unsere Bankverbindung überwiesen. Wenn Sie Vorkasse bezahlen, wird die Ware versendet sobald der Betrag auf unserem Konto gutgeschrieben ist.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Eigentumsvorbehalt
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Die gelieferte Ware bleibt so lange Eigentum von Growheads, bis der Käufer alle gegen ihn bestehenden Forderungen beglichen hat. Veräußert der Verkäufer die Ware, so tritt er schon jetzt die ihm aus dem Verkauf zustehenden Forderungen an uns ab. Kommt der Käufer mit seinen Zahlungen in Verzug, so können wir jederzeit die Herausgabe der Ware verlangen, ohne vom Vertrag zurückzutreten.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Belehrung nach Fernabsatzgesetz
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Die nachfolgende Belehrung gilt nur für Verträge, die zwischen Growheads und Verbrauchern durch Katalogbestellung, Internetbestellung oder durch sonstige Fern-Kommunikationsmittel zustande gekommen sind. Sie ist auf Verbraucher innerhalb der EG beschränkt.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
1. Wesentliche Merkmale der Ware
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Die wesentlichen Merkmale der Ware entnehmen Sie bitte den Erläuterungen im Katalog oder unserer Web-Site. Die Angebote in unserem Katalog und auf unserer Web-Site sind freibleibend. Bestellungen an uns verstehen sich als bindende Angebote. Diese kann Growheads innerhalb einer Frist von 14 Tagen ab Zugang der Bestellung durch eine Auftragsbestätigung oder durch Zusendung der Ware annehmen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
2. Vorbehalt
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Sollten nicht alle bestellten Artikel lieferbar sein, behalten wir uns Teillieferungen vor, soweit diese dem Kunden zumutbar sind. Einzelne Artikel können von den Abbildungen und Beschreibungen im Katalog und auf der Webseite eventuell abweichen. Dies gilt natürlich im Besonderen für Waren, die in Handarbeit gefertigt werden. Wir behalten uns daher vor, unter Umständen in Qualität und Preis gleichwertige Waren zu liefern.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
3. Preise und Steuern
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Die Preise der einzelnen Artikel inklusive Mehrwertsteuer können Sie dem Katalog bzw. unserer Website entnehmen. Die Preise verlieren ihre Gültigkeit mit Erscheinen eines neuen Kataloges.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
4. Alle Preise sind unter dem Vorbehalt von Fehlern oder Preisschwankungen. Sollte es zu einer Preisänderung kommen, so kann der Käufer von seinem Rückgaberecht gebrauch machen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
5. Gewährleistungsfrist
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Es gilt die gesetzliche Gewährleistungsfrist von 24 (vierundzwanzig) Monaten. Im Einzelfall können längere Fristen gelten, wenn diese vom Hersteller gewährt werden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
6. Rückgaberecht / Widerrufsrecht
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Dem Kunden steht ein 14-tägiges Rückgaberecht zu.
|
||||
Die Frist hierzu beginnt mit dem Eingang der Ware beim Kunden und ist gewahrt durch die rechtzeitige Absendung des Widerrufs an Growheads. Ausgenommen davon sind Lebensmittel und andere verderbliche Waren, sowie Spezialanfertigungen, oder Waren, die extra auf Wunsch des Kunden bestellt wurden. Die Rückgabe hat durch Rücksendung der Ware innerhalb der Frist zu erfolgen. Kann die Ware nicht versandt werden, so ist innerhalb der Frist ein Rücknahmeverlangen durch Brief, Postkarte, Email oder einen anderen dauerhaften Datenträger an uns zu richten. Zur Fristwahrung genügt die rechtzeitige Absendung an die unter 7) genannte Unternehmensanschrift. Der Widerruf bedarf keiner Begründung. Der Kaufpreis sowie eventuelle Liefer- und Versandkosten werden nach Eingang der Ware bei uns zurückerstattet. Entscheidend ist der Wert der zurückgesandten Ware zum Zeitpunkt des Kaufs, nicht der Wert der gesamten Bestellung. Growheads kann in der Regel eine Abholung bei Ihnen veranlassen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
7. Name und Anschrift des Unternehmens, Beanstandungen, Ladungen
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Growheads<br />
|
||||
Trachenberger Straße 14<br />
|
||||
01129 Dresden
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
8. Erfüllungsort und Gerichtsstand
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Erfüllungsort und Gerichtsstand für alle Ansprüche ist Dresden, soweit nicht zwingende gesetzliche Vorschriften dem entgegenstehen.
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
|
||||
return <LegalPage title="Allgemeine Geschäftsbedingungen" content={content} />;
|
||||
};
|
||||
|
||||
export default AGB;
|
||||
560
src/pages/AdminPage.js
Normal file
560
src/pages/AdminPage.js
Normal file
@@ -0,0 +1,560 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Paper,
|
||||
Box,
|
||||
Divider,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Tabs,
|
||||
Tab
|
||||
} from '@mui/material';
|
||||
import { Navigate, Link } from 'react-router-dom';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ADMIN_COLORS, getAdminStyles } from '../theme/adminColors.js';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
class AdminPage extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
users: {},
|
||||
user: null,
|
||||
stats: null,
|
||||
loading: true,
|
||||
redirect: false
|
||||
};
|
||||
}
|
||||
|
||||
checkUserLoggedIn = () => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (!storedUser) {
|
||||
this.setState({ redirect: true, user: null });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userData = JSON.parse(storedUser);
|
||||
if (!userData) {
|
||||
this.setState({ redirect: true, user: null });
|
||||
} else if (!this.state.user) {
|
||||
// Only update user if it's not already set
|
||||
this.setState({ user: userData, loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing user from sessionStorage:', error);
|
||||
this.setState({ redirect: true, user: null });
|
||||
}
|
||||
|
||||
// Once loading is complete
|
||||
if (this.state.loading) {
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleStorageChange = (e) => {
|
||||
if (e.key === 'user' && !e.newValue) {
|
||||
// User was removed from sessionStorage in another tab
|
||||
this.setState({ redirect: true, user: null });
|
||||
}
|
||||
}
|
||||
handleCartUpdated = (id,user,cart,id2) => {
|
||||
const users = this.state.users;
|
||||
if(user && user.email) id = user.email;
|
||||
if(id2) id=id2;
|
||||
if(cart) users[id] = cart;
|
||||
if(!users[id]) delete users[id];
|
||||
console.log(users);
|
||||
this.setState({ users });
|
||||
}
|
||||
|
||||
|
||||
|
||||
componentDidMount() {
|
||||
this.loadInitialData();
|
||||
this.addSocketListeners();
|
||||
this.checkUserLoggedIn();
|
||||
// Set up interval to regularly check login status
|
||||
this.checkLoginInterval = setInterval(this.checkUserLoggedIn, 1000);
|
||||
// Add storage event listener to detect when user logs out in other tabs
|
||||
window.addEventListener('storage', this.handleStorageChange);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected) {
|
||||
// Socket just connected, add listeners and reload data
|
||||
this.addSocketListeners();
|
||||
this.loadInitialData();
|
||||
} else if (wasConnected && !isNowConnected) {
|
||||
// Socket just disconnected, remove listeners
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.removeSocketListeners();
|
||||
// Clear interval and remove event listeners
|
||||
if (this.checkLoginInterval) {
|
||||
clearInterval(this.checkLoginInterval);
|
||||
}
|
||||
window.removeEventListener('storage', this.handleStorageChange);
|
||||
}
|
||||
|
||||
loadInitialData = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('getStats', (stats) => {
|
||||
console.log('AdminPage: getStats', JSON.stringify(stats,null,2));
|
||||
this.setState({stats: stats});
|
||||
});
|
||||
this.props.socket.emit('initialCarts', (carts) => {
|
||||
console.log('AdminPage: initialCarts', carts);
|
||||
if(carts && carts.success == true)
|
||||
{
|
||||
const users = {};
|
||||
for(const item of carts.carts){
|
||||
const user = {email:item.email};
|
||||
let id = item.clientUrlId || item.socketId;
|
||||
const cart = item.cart;
|
||||
if(user && user.email) id = user.email;
|
||||
if(cart) users[id] = cart;
|
||||
}
|
||||
this.setState({ users: users });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
formatPrice = (price) => {
|
||||
return typeof price === 'number'
|
||||
? `€${price.toFixed(2)}`
|
||||
: price;
|
||||
}
|
||||
|
||||
prepareChartData = () => {
|
||||
if (!this.state.stats || !this.state.stats.data || !this.state.stats.data.last30Days) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dailyData = this.state.stats.data.last30Days.dailyData || [];
|
||||
|
||||
// Sort data by date to ensure proper chronological order
|
||||
const sortedData = [...dailyData].sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
|
||||
const labels = sortedData.map(item => {
|
||||
const date = new Date(item.date);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
});
|
||||
|
||||
const socketConnections = sortedData.map(item => item.socket_connections || 0);
|
||||
const productViewCalls = sortedData.map(item => item.get_product_view_calls || 0);
|
||||
|
||||
return {
|
||||
labels,
|
||||
socketConnections,
|
||||
productViewCalls
|
||||
};
|
||||
}
|
||||
|
||||
getSocketConnectionsChartData = () => {
|
||||
const data = this.prepareChartData();
|
||||
if (!data) return null;
|
||||
|
||||
return {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Site Visits',
|
||||
data: data.socketConnections,
|
||||
borderColor: '#8be9fd', // terminal.ansiCyan
|
||||
backgroundColor: 'rgba(139, 233, 253, 0.2)', // terminal.ansiCyan with transparency
|
||||
tension: 0.1,
|
||||
pointBackgroundColor: '#8be9fd',
|
||||
pointBorderColor: '#8be9fd',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
getProductViewCallsChartData = () => {
|
||||
const data = this.prepareChartData();
|
||||
if (!data) return null;
|
||||
|
||||
return {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Product Detail Page Visits',
|
||||
data: data.productViewCalls,
|
||||
backgroundColor: 'rgba(255, 121, 198, 0.2)', // terminal.ansiMagenta with transparency
|
||||
borderColor: '#ff79c6', // terminal.ansiMagenta
|
||||
borderWidth: 2,
|
||||
tension: 0.1,
|
||||
pointBackgroundColor: '#ff79c6',
|
||||
pointBorderColor: '#ff79c6',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
getChartOptions = (title) => ({
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
font: {
|
||||
family: ADMIN_COLORS.fontFamily
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: title,
|
||||
color: ADMIN_COLORS.primary,
|
||||
font: {
|
||||
family: ADMIN_COLORS.fontFamily,
|
||||
weight: 'bold'
|
||||
}
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
font: {
|
||||
family: ADMIN_COLORS.fontFamily
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: ADMIN_COLORS.border
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
font: {
|
||||
family: ADMIN_COLORS.fontFamily
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: ADMIN_COLORS.border
|
||||
}
|
||||
},
|
||||
},
|
||||
backgroundColor: ADMIN_COLORS.surfaceBackground,
|
||||
color: ADMIN_COLORS.primaryText
|
||||
})
|
||||
|
||||
render() {
|
||||
const { users } = this.state;
|
||||
|
||||
if (this.state.redirect || (!this.state.loading && !this.state.user)) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
// Check if current user is admin
|
||||
if (this.state.user && !this.state.user.admin) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
const hasUsers = Object.keys(users).length > 0;
|
||||
|
||||
const socketConnectionsData = this.getSocketConnectionsChartData();
|
||||
const productViewCallsData = this.getProductViewCallsChartData();
|
||||
|
||||
const styles = getAdminStyles();
|
||||
|
||||
return (
|
||||
<Box sx={styles.pageContainer}>
|
||||
<Container
|
||||
maxWidth="lg"
|
||||
sx={{
|
||||
py: 6
|
||||
}}
|
||||
>
|
||||
{/* Admin Navigation Tabs */}
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
mb: 3,
|
||||
...styles.tabBar
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={0}
|
||||
indicatorColor="primary"
|
||||
sx={{
|
||||
px: 2,
|
||||
'& .MuiTabs-indicator': {
|
||||
backgroundColor: ADMIN_COLORS.primary
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
label="Dashboard"
|
||||
component={Link}
|
||||
to="/admin"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: ADMIN_COLORS.primary,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Users"
|
||||
component={Link}
|
||||
to="/admin/users"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
'&:hover': {
|
||||
color: ADMIN_COLORS.primaryBright
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Server Logs"
|
||||
component={Link}
|
||||
to="/admin/logs"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
'&:hover': {
|
||||
color: ADMIN_COLORS.primaryBright
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
{/* Analytics Charts Section */}
|
||||
{(socketConnectionsData || productViewCallsData) && (
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
...styles.contentPaper
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
gutterBottom
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
...styles.primaryHeading
|
||||
}}
|
||||
>
|
||||
<BarChartIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
|
||||
30-Day Analytics
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3} sx={{ mt: 1 }}>
|
||||
{socketConnectionsData && (
|
||||
<Grid size={{ xs: 12, lg: 6 }}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={styles.card}
|
||||
>
|
||||
<CardContent sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<Line
|
||||
data={socketConnectionsData}
|
||||
options={this.getChartOptions('Site Visits')}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{productViewCallsData && (
|
||||
<Grid size={{ xs: 12, lg: 6 }}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={styles.card}
|
||||
>
|
||||
<CardContent sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<Line
|
||||
data={productViewCallsData}
|
||||
options={this.getChartOptions('Product Detail Page Visits')}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
...styles.contentPaper
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
gutterBottom
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
...styles.primaryHeading
|
||||
}}
|
||||
>
|
||||
<ShoppingCartIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
|
||||
Active User Carts
|
||||
</Typography>
|
||||
|
||||
{!hasUsers && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
mt: 2,
|
||||
...styles.secondaryText
|
||||
}}
|
||||
>
|
||||
No active user carts at the moment.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{hasUsers && (
|
||||
<Grid container spacing={3} sx={{ mt: 1 }}>
|
||||
{Object.keys(users).map((user, i) => {
|
||||
const cartItems = Object.keys(users[user]);
|
||||
const totalValue = cartItems.reduce((total, item) => {
|
||||
return total + (parseFloat(users[user][item].price) || 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Grid size={{ xs: 12, md: 6 }} key={i}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={styles.card}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
...styles.primaryHeading
|
||||
}}
|
||||
>
|
||||
<PersonIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
|
||||
{user}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ mb: 2, borderColor: ADMIN_COLORS.border }} />
|
||||
|
||||
<List disablePadding>
|
||||
{cartItems.map((item, j) => (
|
||||
<ListItem
|
||||
key={j}
|
||||
divider={j < cartItems.length - 1}
|
||||
sx={{
|
||||
py: 1,
|
||||
borderBottom: j < cartItems.length - 1 ? `1px solid ${ADMIN_COLORS.border}` : 'none'
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={users[user][item].name}
|
||||
secondary={users[user][item].quantity+' x '+this.formatPrice(users[user][item].price)}
|
||||
primaryTypographyProps={{
|
||||
fontWeight: 'medium',
|
||||
...styles.primaryText
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
color: ADMIN_COLORS.warning,
|
||||
fontWeight: 'bold',
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ mt: 2, textAlign: 'right' }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: ADMIN_COLORS.primaryBright,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
>
|
||||
Total: {this.formatPrice(totalValue)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AdminPage;
|
||||
35
src/pages/Batteriegesetzhinweise.js
Normal file
35
src/pages/Batteriegesetzhinweise.js
Normal 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
149
src/pages/Datenschutz.js
Normal 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. 6 (1) lit. a 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. 6 (1) lit. b 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. 6 (1) lit. a 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. 6 Abs. 1 lit. f 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. 28 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 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. 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 94103, USA (für Kunden im Europäischen Wirtschaftsraum: Stripe Payments Europe Ltd., 1 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. B. Kreditkartendaten) sowie die IP-Adresse. Die Datenverarbeitung erfolgt zum Zweck der Zahlungsabwicklung; Rechtsgrundlage ist Art. 6 Abs. 1 lit. b 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. 28 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. 46 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 <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;
|
||||
525
src/pages/GrowTentKonfigurator.js
Normal file
525
src/pages/GrowTentKonfigurator.js
Normal 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
651
src/pages/Home.js
Normal 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
52
src/pages/Impressum.js
Normal 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
22
src/pages/LegalPage.js
Normal 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
232
src/pages/ProfilePage.js
Normal 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
176
src/pages/ResetPassword.js
Normal 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
400
src/pages/ServerLogsPage.js
Normal 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
171
src/pages/Sitemap.js
Normal 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
719
src/pages/UsersPage.js
Normal 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;
|
||||
46
src/pages/Widerrufsrecht.js
Normal file
46
src/pages/Widerrufsrecht.js
Normal 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;
|
||||
109
src/providers/GoogleAuthProvider.js
Normal file
109
src/providers/GoogleAuthProvider.js
Normal 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;
|
||||
129
src/providers/SocketProvider.js
Normal file
129
src/providers/SocketProvider.js
Normal 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;
|
||||
119
src/services/telemetryService.js
Normal file
119
src/services/telemetryService.js
Normal 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
40
src/theme.js
Normal 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
68
src/theme/adminColors.js
Normal 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
|
||||
}
|
||||
});
|
||||
26
src/utils/LazyHtmlParser.js
Normal file
26
src/utils/LazyHtmlParser.js
Normal 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);
|
||||
232
src/utils/animatedBorderStyles.js
Normal file
232
src/utils/animatedBorderStyles.js
Normal 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
51
src/utils/cartUtils.js
Normal 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
53
src/utils/nnsort.js
Normal 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];
|
||||
}
|
||||
24
src/utils/sessionStorage.js
Normal file
24
src/utils/sessionStorage.js
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user