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

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

@@ -0,0 +1,176 @@
import React from 'react';
import { Typography } from '@mui/material';
import LegalPage from './LegalPage.js';
const AGB = () => {
const content = (
<>
<Typography variant="h6" gutterBottom>
Liefer- & Versandbedingungen
</Typography>
<Typography variant="body1" paragraph>
1. Der Versand dauert zwischen 1 und 7 Tagen.
</Typography>
<Typography variant="body1" paragraph>
2. Die Ware bleibt bis zur vollständigen Bezahlung Eigentum von Growheads.
</Typography>
<Typography variant="body1" paragraph>
3. Bei der Vermutung, dass die Ware durch den Transport beschädigt wurde oder Ware fehlt, ist die Versandverpackung zur Ansicht durch einen Gutachter aufzubewahren. Eine Beschädigung der Verpackung ist durch den Transporteur nach Art und Umfang auf dem Lieferschein zu bestätigen. Versandschäden müssen sofort schriftlich per Fax, Email oder Post an Growheads gemeldet werden. Dafür müssen Fotos von der beschädigten Ware sowie von dem beschädigten Versandkarton samt Adressaufkleber erstellt werden. Der beschädigte Versandkarton ist auch aufzubewahren. Diese werden benötigt um den Schaden der Transportfirma in Rechnung zu stellen.
</Typography>
<Typography variant="body1" paragraph>
4. Bei der Rücksendung mangelhafter Ware hat der Kunde Sorge zu tragen, dass die Ware ordnungsgemäß verpackt wird.
</Typography>
<Typography variant="body1" paragraph>
5. Alle Rücksendungen sind vorher bei Growheads anzumelden.
</Typography>
<Typography variant="body1" paragraph>
6. Für das Zusenden von Gegenständen an uns trägt der Kunde die Gefahr, soweit es sich dabei nicht um die Rücksendung mangelhafter Ware handelt.
</Typography>
<Typography variant="body1" paragraph>
7. Growheads ist berechtigt, die Ware durch die Deutsche Post/GLS oder einen Spediteur seiner Wahl, abholen zu lassen.
</Typography>
<Typography variant="body1" paragraph>
8. Die Portokosten werden nach Gewicht berechnet. Eventuelle Preiserhöhungen der Transportunternehmen (Maut, Treibstoffzuschläge) behält sich Growheads vor.
</Typography>
<Typography variant="body1" paragraph>
9. Unsere Pakete werden in der Regel versendet mit: GLS, DHL & der Deutschen Post AG.
</Typography>
<Typography variant="body1" paragraph>
10. Bei besonders schweren oder sperrigen Artikeln behalten wir uns Zuschläge auf die Lieferkosten vor. In der Regel sind diese Zuschläge in der Preisliste aufgeführt.
</Typography>
<Typography variant="body1" paragraph>
11. Es kann per Vorkasse an die angegebene Bankverbindung überwiesen werden.
</Typography>
<Typography variant="body1" paragraph>
12. Kommt es zu einer Lieferverzögerung, die von uns zu vertreten ist, so ist die Dauer der Nachfrist, die der Käufer zu setzen berechtigt ist, auf zwei Wochen festgelegt. Die Frist beginnt mit Eingang der Nachfristsetzung bei Growheads.
</Typography>
<Typography variant="body1" paragraph>
13. Offensichtliche Mängel der Ware ist sofort nach Lieferung schriftlich anzuzeigen. Kommt der Kunde dieser Verpflichtung nicht nach, so sind Gewährleistungsansprüche wegen offensichtlicher Mängel ausgeschlossen.
</Typography>
<Typography variant="body1" paragraph>
14. Rügt der Kunde einen Mangel, so hat er die mangelhafte Ware mit einer möglichst genauen Fehlerbeschreibung an uns zurück zu senden. Der Sendung ist eine Kopie unserer Rechnung beizulegen. Die Ware ist in der Originalverpackung zurück zu senden oder aber in einer Verpackung, welche die Ware entsprechend der Originalverpackung schützt, so dass Schäden auf dem Rücktransport vermieden werden.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Beratung und Haftung
</Typography>
<Typography variant="body1" paragraph>
1. Anwendungstechnische Beratung geben wir nach bestem Wissen aufgrund des Standes unserer Erfahrung und Kenntnisse.
</Typography>
<Typography variant="body1" paragraph>
2. Für die Beachtung gesetzlicher Vorschriften bei Lagerung, Weitertransport und Verwendung unserer Waren ist der Käufer verantwortlich.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Zahlungsbedingungen
</Typography>
<Typography variant="body1" paragraph>
1. Die Ware bleibt bis zur vollständigen Bezahlung Eigentum von Growheads.
</Typography>
<Typography variant="body1" paragraph>
2. Rechnungen werden per Vorkasse auf unsere Bankverbindung überwiesen. Wenn Sie Vorkasse bezahlen, wird die Ware versendet sobald der Betrag auf unserem Konto gutgeschrieben ist.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Eigentumsvorbehalt
</Typography>
<Typography variant="body1" paragraph>
Die gelieferte Ware bleibt so lange Eigentum von Growheads, bis der Käufer alle gegen ihn bestehenden Forderungen beglichen hat. Veräußert der Verkäufer die Ware, so tritt er schon jetzt die ihm aus dem Verkauf zustehenden Forderungen an uns ab. Kommt der Käufer mit seinen Zahlungen in Verzug, so können wir jederzeit die Herausgabe der Ware verlangen, ohne vom Vertrag zurückzutreten.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Belehrung nach Fernabsatzgesetz
</Typography>
<Typography variant="body1" paragraph>
Die nachfolgende Belehrung gilt nur für Verträge, die zwischen Growheads und Verbrauchern durch Katalogbestellung, Internetbestellung oder durch sonstige Fern-Kommunikationsmittel zustande gekommen sind. Sie ist auf Verbraucher innerhalb der EG beschränkt.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
1. Wesentliche Merkmale der Ware
</Typography>
<Typography variant="body1" paragraph>
Die wesentlichen Merkmale der Ware entnehmen Sie bitte den Erläuterungen im Katalog oder unserer Web-Site. Die Angebote in unserem Katalog und auf unserer Web-Site sind freibleibend. Bestellungen an uns verstehen sich als bindende Angebote. Diese kann Growheads innerhalb einer Frist von 14 Tagen ab Zugang der Bestellung durch eine Auftragsbestätigung oder durch Zusendung der Ware annehmen.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
2. Vorbehalt
</Typography>
<Typography variant="body1" paragraph>
Sollten nicht alle bestellten Artikel lieferbar sein, behalten wir uns Teillieferungen vor, soweit diese dem Kunden zumutbar sind. Einzelne Artikel können von den Abbildungen und Beschreibungen im Katalog und auf der Webseite eventuell abweichen. Dies gilt natürlich im Besonderen für Waren, die in Handarbeit gefertigt werden. Wir behalten uns daher vor, unter Umständen in Qualität und Preis gleichwertige Waren zu liefern.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
3. Preise und Steuern
</Typography>
<Typography variant="body1" paragraph>
Die Preise der einzelnen Artikel inklusive Mehrwertsteuer können Sie dem Katalog bzw. unserer Website entnehmen. Die Preise verlieren ihre Gültigkeit mit Erscheinen eines neuen Kataloges.
</Typography>
<Typography variant="body1" paragraph>
4. Alle Preise sind unter dem Vorbehalt von Fehlern oder Preisschwankungen. Sollte es zu einer Preisänderung kommen, so kann der Käufer von seinem Rückgaberecht gebrauch machen.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
5. Gewährleistungsfrist
</Typography>
<Typography variant="body1" paragraph>
Es gilt die gesetzliche Gewährleistungsfrist von 24 (vierundzwanzig) Monaten. Im Einzelfall können längere Fristen gelten, wenn diese vom Hersteller gewährt werden.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
6. Rückgaberecht / Widerrufsrecht
</Typography>
<Typography variant="body1" paragraph>
Dem Kunden steht ein 14-tägiges Rückgaberecht zu.
Die Frist hierzu beginnt mit dem Eingang der Ware beim Kunden und ist gewahrt durch die rechtzeitige Absendung des Widerrufs an Growheads. Ausgenommen davon sind Lebensmittel und andere verderbliche Waren, sowie Spezialanfertigungen, oder Waren, die extra auf Wunsch des Kunden bestellt wurden. Die Rückgabe hat durch Rücksendung der Ware innerhalb der Frist zu erfolgen. Kann die Ware nicht versandt werden, so ist innerhalb der Frist ein Rücknahmeverlangen durch Brief, Postkarte, Email oder einen anderen dauerhaften Datenträger an uns zu richten. Zur Fristwahrung genügt die rechtzeitige Absendung an die unter 7) genannte Unternehmensanschrift. Der Widerruf bedarf keiner Begründung. Der Kaufpreis sowie eventuelle Liefer- und Versandkosten werden nach Eingang der Ware bei uns zurückerstattet. Entscheidend ist der Wert der zurückgesandten Ware zum Zeitpunkt des Kaufs, nicht der Wert der gesamten Bestellung. Growheads kann in der Regel eine Abholung bei Ihnen veranlassen.
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
7. Name und Anschrift des Unternehmens, Beanstandungen, Ladungen
</Typography>
<Typography variant="body1" paragraph>
Growheads<br />
Trachenberger Straße 14<br />
01129 Dresden
</Typography>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
8. Erfüllungsort und Gerichtsstand
</Typography>
<Typography variant="body1" paragraph>
Erfüllungsort und Gerichtsstand für alle Ansprüche ist Dresden, soweit nicht zwingende gesetzliche Vorschriften dem entgegenstehen.
</Typography>
</>
);
return <LegalPage title="Allgemeine Geschäftsbedingungen" content={content} />;
};
export default AGB;

560
src/pages/AdminPage.js Normal file
View File

@@ -0,0 +1,560 @@
import React from 'react';
import {
Container,
Typography,
Paper,
Box,
Divider,
Grid,
Card,
CardContent,
List,
ListItem,
ListItemText,
Tabs,
Tab
} from '@mui/material';
import { Navigate, Link } from 'react-router-dom';
import PersonIcon from '@mui/icons-material/Person';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import BarChartIcon from '@mui/icons-material/BarChart';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
Legend,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
import { ADMIN_COLORS, getAdminStyles } from '../theme/adminColors.js';
ChartJS.register(
CategoryScale,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
Legend
);
class AdminPage extends React.Component {
constructor(props) {
super(props);
this.state = {
users: {},
user: null,
stats: null,
loading: true,
redirect: false
};
}
checkUserLoggedIn = () => {
const storedUser = sessionStorage.getItem('user');
if (!storedUser) {
this.setState({ redirect: true, user: null });
return;
}
try {
const userData = JSON.parse(storedUser);
if (!userData) {
this.setState({ redirect: true, user: null });
} else if (!this.state.user) {
// Only update user if it's not already set
this.setState({ user: userData, loading: false });
}
} catch (error) {
console.error('Error parsing user from sessionStorage:', error);
this.setState({ redirect: true, user: null });
}
// Once loading is complete
if (this.state.loading) {
this.setState({ loading: false });
}
}
handleStorageChange = (e) => {
if (e.key === 'user' && !e.newValue) {
// User was removed from sessionStorage in another tab
this.setState({ redirect: true, user: null });
}
}
handleCartUpdated = (id,user,cart,id2) => {
const users = this.state.users;
if(user && user.email) id = user.email;
if(id2) id=id2;
if(cart) users[id] = cart;
if(!users[id]) delete users[id];
console.log(users);
this.setState({ users });
}
componentDidMount() {
this.loadInitialData();
this.addSocketListeners();
this.checkUserLoggedIn();
// Set up interval to regularly check login status
this.checkLoginInterval = setInterval(this.checkUserLoggedIn, 1000);
// Add storage event listener to detect when user logs out in other tabs
window.addEventListener('storage', this.handleStorageChange);
}
componentDidUpdate(prevProps) {
// Handle socket connection changes
const wasConnected = prevProps.socket && prevProps.socket.connected;
const isNowConnected = this.props.socket && this.props.socket.connected;
if (!wasConnected && isNowConnected) {
// Socket just connected, add listeners and reload data
this.addSocketListeners();
this.loadInitialData();
} else if (wasConnected && !isNowConnected) {
// Socket just disconnected, remove listeners
this.removeSocketListeners();
}
}
componentWillUnmount() {
this.removeSocketListeners();
// Clear interval and remove event listeners
if (this.checkLoginInterval) {
clearInterval(this.checkLoginInterval);
}
window.removeEventListener('storage', this.handleStorageChange);
}
loadInitialData = () => {
if (this.props.socket && this.props.socket.connected) {
this.props.socket.emit('getStats', (stats) => {
console.log('AdminPage: getStats', JSON.stringify(stats,null,2));
this.setState({stats: stats});
});
this.props.socket.emit('initialCarts', (carts) => {
console.log('AdminPage: initialCarts', carts);
if(carts && carts.success == true)
{
const users = {};
for(const item of carts.carts){
const user = {email:item.email};
let id = item.clientUrlId || item.socketId;
const cart = item.cart;
if(user && user.email) id = user.email;
if(cart) users[id] = cart;
}
this.setState({ users: users });
}
});
}
}
addSocketListeners = () => {
if (this.props.socket && this.props.socket.connected) {
// Remove existing listeners first to avoid duplicates
this.removeSocketListeners();
this.props.socket.on('cartUpdated', this.handleCartUpdated);
}
}
removeSocketListeners = () => {
if (this.props.socket) {
this.props.socket.off('cartUpdated', this.handleCartUpdated);
}
}
formatPrice = (price) => {
return typeof price === 'number'
? `${price.toFixed(2)}`
: price;
}
prepareChartData = () => {
if (!this.state.stats || !this.state.stats.data || !this.state.stats.data.last30Days) {
return null;
}
const dailyData = this.state.stats.data.last30Days.dailyData || [];
// Sort data by date to ensure proper chronological order
const sortedData = [...dailyData].sort((a, b) => new Date(a.date) - new Date(b.date));
const labels = sortedData.map(item => {
const date = new Date(item.date);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
});
const socketConnections = sortedData.map(item => item.socket_connections || 0);
const productViewCalls = sortedData.map(item => item.get_product_view_calls || 0);
return {
labels,
socketConnections,
productViewCalls
};
}
getSocketConnectionsChartData = () => {
const data = this.prepareChartData();
if (!data) return null;
return {
labels: data.labels,
datasets: [
{
label: 'Site Visits',
data: data.socketConnections,
borderColor: '#8be9fd', // terminal.ansiCyan
backgroundColor: 'rgba(139, 233, 253, 0.2)', // terminal.ansiCyan with transparency
tension: 0.1,
pointBackgroundColor: '#8be9fd',
pointBorderColor: '#8be9fd',
},
],
};
}
getProductViewCallsChartData = () => {
const data = this.prepareChartData();
if (!data) return null;
return {
labels: data.labels,
datasets: [
{
label: 'Product Detail Page Visits',
data: data.productViewCalls,
backgroundColor: 'rgba(255, 121, 198, 0.2)', // terminal.ansiMagenta with transparency
borderColor: '#ff79c6', // terminal.ansiMagenta
borderWidth: 2,
tension: 0.1,
pointBackgroundColor: '#ff79c6',
pointBorderColor: '#ff79c6',
},
],
};
}
getChartOptions = (title) => ({
responsive: true,
plugins: {
legend: {
position: 'top',
labels: {
color: ADMIN_COLORS.primaryText,
font: {
family: ADMIN_COLORS.fontFamily
}
}
},
title: {
display: true,
text: title,
color: ADMIN_COLORS.primary,
font: {
family: ADMIN_COLORS.fontFamily,
weight: 'bold'
}
},
},
scales: {
x: {
beginAtZero: true,
ticks: {
color: ADMIN_COLORS.primaryText,
font: {
family: ADMIN_COLORS.fontFamily
}
},
grid: {
color: ADMIN_COLORS.border
}
},
y: {
beginAtZero: true,
ticks: {
color: ADMIN_COLORS.primaryText,
font: {
family: ADMIN_COLORS.fontFamily
}
},
grid: {
color: ADMIN_COLORS.border
}
},
},
backgroundColor: ADMIN_COLORS.surfaceBackground,
color: ADMIN_COLORS.primaryText
})
render() {
const { users } = this.state;
if (this.state.redirect || (!this.state.loading && !this.state.user)) {
return <Navigate to="/" />;
}
// Check if current user is admin
if (this.state.user && !this.state.user.admin) {
return <Navigate to="/" />;
}
const hasUsers = Object.keys(users).length > 0;
const socketConnectionsData = this.getSocketConnectionsChartData();
const productViewCallsData = this.getProductViewCallsChartData();
const styles = getAdminStyles();
return (
<Box sx={styles.pageContainer}>
<Container
maxWidth="lg"
sx={{
py: 6
}}
>
{/* Admin Navigation Tabs */}
<Paper
elevation={1}
sx={{
mb: 3,
...styles.tabBar
}}
>
<Tabs
value={0}
indicatorColor="primary"
sx={{
px: 2,
'& .MuiTabs-indicator': {
backgroundColor: ADMIN_COLORS.primary
}
}}
>
<Tab
label="Dashboard"
component={Link}
to="/admin"
sx={{
textTransform: 'none',
fontWeight: 'bold',
color: ADMIN_COLORS.primary,
fontFamily: ADMIN_COLORS.fontFamily
}}
/>
<Tab
label="Users"
component={Link}
to="/admin/users"
sx={{
textTransform: 'none',
color: ADMIN_COLORS.primaryText,
fontFamily: ADMIN_COLORS.fontFamily,
'&:hover': {
color: ADMIN_COLORS.primaryBright
}
}}
/>
<Tab
label="Server Logs"
component={Link}
to="/admin/logs"
sx={{
textTransform: 'none',
color: ADMIN_COLORS.primaryText,
fontFamily: ADMIN_COLORS.fontFamily,
'&:hover': {
color: ADMIN_COLORS.primaryBright
}
}}
/>
</Tabs>
</Paper>
{/* Analytics Charts Section */}
{(socketConnectionsData || productViewCallsData) && (
<Paper
elevation={3}
sx={{
p: 3,
mb: 4,
...styles.contentPaper
}}
>
<Typography
variant="h5"
gutterBottom
sx={{
display: 'flex',
alignItems: 'center',
...styles.primaryHeading
}}
>
<BarChartIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
30-Day Analytics
</Typography>
<Grid container spacing={3} sx={{ mt: 1 }}>
{socketConnectionsData && (
<Grid size={{ xs: 12, lg: 6 }}>
<Card
variant="outlined"
sx={styles.card}
>
<CardContent sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ flexGrow: 1, minHeight: 0 }}>
<Line
data={socketConnectionsData}
options={this.getChartOptions('Site Visits')}
/>
</Box>
</CardContent>
</Card>
</Grid>
)}
{productViewCallsData && (
<Grid size={{ xs: 12, lg: 6 }}>
<Card
variant="outlined"
sx={styles.card}
>
<CardContent sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ flexGrow: 1, minHeight: 0 }}>
<Line
data={productViewCallsData}
options={this.getChartOptions('Product Detail Page Visits')}
/>
</Box>
</CardContent>
</Card>
</Grid>
)}
</Grid>
</Paper>
)}
<Paper
elevation={3}
sx={{
p: 3,
mb: 4,
...styles.contentPaper
}}
>
<Typography
variant="h5"
gutterBottom
sx={{
display: 'flex',
alignItems: 'center',
...styles.primaryHeading
}}
>
<ShoppingCartIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
Active User Carts
</Typography>
{!hasUsers && (
<Typography
variant="body1"
sx={{
mt: 2,
...styles.secondaryText
}}
>
No active user carts at the moment.
</Typography>
)}
{hasUsers && (
<Grid container spacing={3} sx={{ mt: 1 }}>
{Object.keys(users).map((user, i) => {
const cartItems = Object.keys(users[user]);
const totalValue = cartItems.reduce((total, item) => {
return total + (parseFloat(users[user][item].price) || 0);
}, 0);
return (
<Grid size={{ xs: 12, md: 6 }} key={i}>
<Card
variant="outlined"
sx={styles.card}
>
<CardContent>
<Typography
variant="h6"
sx={{
display: 'flex',
alignItems: 'center',
mb: 2,
...styles.primaryHeading
}}
>
<PersonIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
{user}
</Typography>
<Divider sx={{ mb: 2, borderColor: ADMIN_COLORS.border }} />
<List disablePadding>
{cartItems.map((item, j) => (
<ListItem
key={j}
divider={j < cartItems.length - 1}
sx={{
py: 1,
borderBottom: j < cartItems.length - 1 ? `1px solid ${ADMIN_COLORS.border}` : 'none'
}}
>
<ListItemText
primary={users[user][item].name}
secondary={users[user][item].quantity+' x '+this.formatPrice(users[user][item].price)}
primaryTypographyProps={{
fontWeight: 'medium',
...styles.primaryText
}}
secondaryTypographyProps={{
color: ADMIN_COLORS.warning,
fontWeight: 'bold',
fontFamily: ADMIN_COLORS.fontFamily
}}
/>
</ListItem>
))}
</List>
<Box sx={{ mt: 2, textAlign: 'right' }}>
<Typography
variant="subtitle1"
sx={{
fontWeight: 'bold',
color: ADMIN_COLORS.primaryBright,
fontFamily: ADMIN_COLORS.fontFamily
}}
>
Total: {this.formatPrice(totalValue)}
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
);
})}
</Grid>
)}
</Paper>
</Container>
</Box>
);
}
}
export default AdminPage;

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,400 @@
import React from 'react';
import {
Container,
Typography,
Paper,
Card,
CardContent,
List,
ListItem,
Tabs,
Tab,
Stack,
Button,
Box
} from '@mui/material';
import { Navigate, Link } from 'react-router-dom';
import ArticleIcon from '@mui/icons-material/Article';
import { ADMIN_COLORS, getAdminStyles } from '../theme/adminColors.js';
class ServerLogsPage extends React.Component {
constructor(props) {
super(props);
this.state = {
user: null,
loading: true,
redirect: false,
logs: [],
historicalLogsLoaded: false
};
}
checkUserLoggedIn = () => {
const storedUser = sessionStorage.getItem('user');
if (!storedUser) {
this.setState({ redirect: true, user: null });
return;
}
try {
const userData = JSON.parse(storedUser);
if (!userData) {
this.setState({ redirect: true, user: null });
} else if (!this.state.user) {
// Only update user if it's not already set
this.setState({ user: userData, loading: false });
}
} catch (error) {
console.error('Error parsing user from sessionStorage:', error);
this.setState({ redirect: true, user: null });
}
// Once loading is complete
if (this.state.loading) {
this.setState({ loading: false });
}
}
handleStorageChange = (e) => {
if (e.key === 'user' && !e.newValue) {
// User was removed from sessionStorage in another tab
this.setState({ redirect: true, user: null });
}
}
handleLogEntry = (logEntry) => {
console.log(`[${logEntry.timestamp}] ${logEntry.level.toUpperCase()}: ${logEntry.message}`);
this.setState(prevState => ({
logs: [logEntry, ...prevState.logs].slice(0, 250) // Keep only last 250 logs
}));
}
parseHistoricalLogLine = (logLine) => {
// Try to parse JSON formatted log entries first
try {
const parsed = JSON.parse(logLine);
if (parsed.level && parsed.message && parsed.timestamp) {
return {
timestamp: parsed.timestamp,
level: parsed.level.toLowerCase(),
message: parsed.message
};
}
} catch {
// Not JSON, try other formats
}
// Try to parse bracket format like: "[2024-01-01T12:00:00.000Z] INFO: message content"
const match = logLine.match(/^\[(.+?)\]\s+(\w+):\s*(.*)$/);
if (match) {
return {
timestamp: match[1],
level: match[2].toLowerCase(),
message: match[3]
};
}
// Fallback for lines that don't match expected format
return {
timestamp: new Date().toISOString(),
level: 'info',
message: logLine
};
}
loadHistoricalLogs = () => {
if (this.props.socket && this.props.socket.connected) {
this.props.socket.emit('getLog', (response) => {
if (response.success) {
console.log('Last 50 historical logs:', response.data.lines);
const historicalLogs = (response.data.lines || [])
.map(line => this.parseHistoricalLogLine(line))
.reverse(); // Reverse to have newest first
this.setState(prevState => ({
logs: [...historicalLogs, ...prevState.logs].slice(0, 250),
historicalLogsLoaded: true
}));
} else {
console.warn('Failed to load historical logs:', response);
this.setState({ historicalLogsLoaded: true }); // Mark as attempted even if failed
}
});
}
}
componentDidMount() {
this.addSocketListeners();
this.checkUserLoggedIn();
this.loadHistoricalLogs(); // Load historical logs on mount
// Set up interval to regularly check login status
this.checkLoginInterval = setInterval(this.checkUserLoggedIn, 1000);
// Add storage event listener to detect when user logs out in other tabs
window.addEventListener('storage', this.handleStorageChange);
}
componentDidUpdate(prevProps) {
// Handle socket connection changes
const wasConnected = prevProps.socket && prevProps.socket.connected;
const isNowConnected = this.props.socket && this.props.socket.connected;
if (!wasConnected && isNowConnected) {
// Socket just connected, add listeners and reload data
this.addSocketListeners();
if (!this.state.historicalLogsLoaded) {
this.loadHistoricalLogs();
}
} else if (wasConnected && !isNowConnected) {
// Socket just disconnected, remove listeners
this.removeSocketListeners();
}
}
componentWillUnmount() {
this.removeSocketListeners();
// Clear interval and remove event listeners
if (this.checkLoginInterval) {
clearInterval(this.checkLoginInterval);
}
window.removeEventListener('storage', this.handleStorageChange);
}
addSocketListeners = () => {
if (this.props.socket && this.props.socket.connected) {
// Remove existing listeners first to avoid duplicates
this.removeSocketListeners();
this.props.socket.on('log', this.handleLogEntry);
}
}
removeSocketListeners = () => {
if (this.props.socket) {
this.props.socket.off('log', this.handleLogEntry);
}
}
formatLogLevel = (level) => {
const colors = {
error: ADMIN_COLORS.error,
warn: ADMIN_COLORS.warning,
info: ADMIN_COLORS.primary,
debug: ADMIN_COLORS.secondary,
verbose: ADMIN_COLORS.secondaryText
};
return colors[level.toLowerCase()] || ADMIN_COLORS.primaryText;
}
clearLogs = () => {
this.setState({ logs: [] });
}
render() {
if (this.state.redirect || (!this.state.loading && !this.state.user)) {
return <Navigate to="/" />;
}
// Check if current user is admin
if (this.state.user && !this.state.user.admin) {
return <Navigate to="/" />;
}
const styles = getAdminStyles();
return (
<Box sx={styles.pageContainer}>
<Container
maxWidth="lg"
sx={{
py: 6
}}
>
{/* Admin Navigation Tabs */}
<Paper
elevation={1}
sx={{
mb: 3,
...styles.tabBar
}}
>
<Tabs
value={2}
indicatorColor="primary"
sx={{
px: 2,
'& .MuiTabs-indicator': {
backgroundColor: ADMIN_COLORS.primary
}
}}
>
<Tab
label="Dashboard"
component={Link}
to="/admin"
sx={{
textTransform: 'none',
color: ADMIN_COLORS.primaryText,
fontFamily: ADMIN_COLORS.fontFamily,
'&:hover': {
color: ADMIN_COLORS.primaryBright
}
}}
/>
<Tab
label="Users"
component={Link}
to="/admin/users"
sx={{
textTransform: 'none',
color: ADMIN_COLORS.primaryText,
fontFamily: ADMIN_COLORS.fontFamily,
'&:hover': {
color: ADMIN_COLORS.primaryBright
}
}}
/>
<Tab
label="Server Logs"
component={Link}
to="/admin/logs"
sx={{
textTransform: 'none',
fontWeight: 'bold',
color: ADMIN_COLORS.primary,
fontFamily: ADMIN_COLORS.fontFamily
}}
/>
</Tabs>
</Paper>
{/* Server Logs Content */}
<Paper
elevation={3}
sx={{
p: 3,
mb: 4,
...styles.contentPaper
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography
variant="h5"
sx={{
display: 'flex',
alignItems: 'center',
...styles.primaryHeading
}}
>
<ArticleIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
Server Logs
</Typography>
<Button
variant="outlined"
onClick={this.clearLogs}
size="small"
sx={{
color: ADMIN_COLORS.warning,
borderColor: ADMIN_COLORS.warning,
fontFamily: ADMIN_COLORS.fontFamily,
textTransform: 'none',
'&:hover': {
backgroundColor: ADMIN_COLORS.warning,
color: ADMIN_COLORS.hoverBackground,
borderColor: ADMIN_COLORS.warning
}
}}
>
Clear Logs
</Button>
</Box>
{this.state.logs.length === 0 ? (
<Typography
variant="body2"
sx={{
...styles.secondaryText,
p: 2
}}
>
{!this.state.historicalLogsLoaded
? "Loading historical logs..."
: "No logs available. New logs will appear here in real-time."}
</Typography>
) : (
<Card
variant="outlined"
sx={{
...styles.card,
borderRadius: 1
}}
>
<CardContent sx={{ p: 0 }}>
<List dense sx={{ p: 0 }}>
{this.state.logs.map((log, index) => (
<ListItem
key={index}
sx={{
py: 0.25,
px: 1,
borderBottom: index < this.state.logs.length - 1 ? `1px solid ${ADMIN_COLORS.hoverBackground}` : 'none',
'&:hover': {
backgroundColor: ADMIN_COLORS.hoverBackground
}
}}
>
<Stack direction="row" spacing={1} alignItems="flex-start" sx={{ width: '100%', fontFamily: ADMIN_COLORS.fontFamily }}>
<Typography
variant="caption"
sx={{
color: ADMIN_COLORS.secondaryText,
minWidth: 160,
fontSize: '0.75rem',
fontFamily: ADMIN_COLORS.fontFamily,
flexShrink: 0
}}
>
{log.timestamp}
</Typography>
<Typography
variant="caption"
sx={{
color: this.formatLogLevel(log.level),
minWidth: 60,
fontSize: '0.75rem',
fontFamily: ADMIN_COLORS.fontFamily,
fontWeight: 'bold',
flexShrink: 0,
textTransform: 'uppercase'
}}
>
{log.level}:
</Typography>
<Typography
variant="body2"
sx={{
flexGrow: 1,
wordBreak: 'break-word',
color: ADMIN_COLORS.primaryText,
fontSize: '0.875rem',
fontFamily: ADMIN_COLORS.fontFamily,
lineHeight: 1.4
}}
>
{log.message}
</Typography>
</Stack>
</ListItem>
))}
</List>
</CardContent>
</Card>
)}
</Paper>
</Container>
</Box>
);
}
}
export default ServerLogsPage;

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

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

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

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

View File

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