Genesis
This commit is contained in:
176
src/pages/AGB.js
Normal file
176
src/pages/AGB.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
import LegalPage from './LegalPage.js';
|
||||
|
||||
const AGB = () => {
|
||||
const content = (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Liefer- & Versandbedingungen
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
1. Der Versand dauert zwischen 1 und 7 Tagen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
2. Die Ware bleibt bis zur vollständigen Bezahlung Eigentum von Growheads.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
3. Bei der Vermutung, dass die Ware durch den Transport beschädigt wurde oder Ware fehlt, ist die Versandverpackung zur Ansicht durch einen Gutachter aufzubewahren. Eine Beschädigung der Verpackung ist durch den Transporteur nach Art und Umfang auf dem Lieferschein zu bestätigen. Versandschäden müssen sofort schriftlich per Fax, Email oder Post an Growheads gemeldet werden. Dafür müssen Fotos von der beschädigten Ware sowie von dem beschädigten Versandkarton samt Adressaufkleber erstellt werden. Der beschädigte Versandkarton ist auch aufzubewahren. Diese werden benötigt um den Schaden der Transportfirma in Rechnung zu stellen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
4. Bei der Rücksendung mangelhafter Ware hat der Kunde Sorge zu tragen, dass die Ware ordnungsgemäß verpackt wird.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
5. Alle Rücksendungen sind vorher bei Growheads anzumelden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
6. Für das Zusenden von Gegenständen an uns trägt der Kunde die Gefahr, soweit es sich dabei nicht um die Rücksendung mangelhafter Ware handelt.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
7. Growheads ist berechtigt, die Ware durch die Deutsche Post/GLS oder einen Spediteur seiner Wahl, abholen zu lassen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
8. Die Portokosten werden nach Gewicht berechnet. Eventuelle Preiserhöhungen der Transportunternehmen (Maut, Treibstoffzuschläge) behält sich Growheads vor.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
9. Unsere Pakete werden in der Regel versendet mit: GLS, DHL & der Deutschen Post AG.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
10. Bei besonders schweren oder sperrigen Artikeln behalten wir uns Zuschläge auf die Lieferkosten vor. In der Regel sind diese Zuschläge in der Preisliste aufgeführt.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
11. Es kann per Vorkasse an die angegebene Bankverbindung überwiesen werden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
12. Kommt es zu einer Lieferverzögerung, die von uns zu vertreten ist, so ist die Dauer der Nachfrist, die der Käufer zu setzen berechtigt ist, auf zwei Wochen festgelegt. Die Frist beginnt mit Eingang der Nachfristsetzung bei Growheads.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
13. Offensichtliche Mängel der Ware ist sofort nach Lieferung schriftlich anzuzeigen. Kommt der Kunde dieser Verpflichtung nicht nach, so sind Gewährleistungsansprüche wegen offensichtlicher Mängel ausgeschlossen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
14. Rügt der Kunde einen Mangel, so hat er die mangelhafte Ware mit einer möglichst genauen Fehlerbeschreibung an uns zurück zu senden. Der Sendung ist eine Kopie unserer Rechnung beizulegen. Die Ware ist in der Originalverpackung zurück zu senden oder aber in einer Verpackung, welche die Ware entsprechend der Originalverpackung schützt, so dass Schäden auf dem Rücktransport vermieden werden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Beratung und Haftung
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
1. Anwendungstechnische Beratung geben wir nach bestem Wissen aufgrund des Standes unserer Erfahrung und Kenntnisse.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
2. Für die Beachtung gesetzlicher Vorschriften bei Lagerung, Weitertransport und Verwendung unserer Waren ist der Käufer verantwortlich.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Zahlungsbedingungen
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
1. Die Ware bleibt bis zur vollständigen Bezahlung Eigentum von Growheads.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
2. Rechnungen werden per Vorkasse auf unsere Bankverbindung überwiesen. Wenn Sie Vorkasse bezahlen, wird die Ware versendet sobald der Betrag auf unserem Konto gutgeschrieben ist.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Eigentumsvorbehalt
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Die gelieferte Ware bleibt so lange Eigentum von Growheads, bis der Käufer alle gegen ihn bestehenden Forderungen beglichen hat. Veräußert der Verkäufer die Ware, so tritt er schon jetzt die ihm aus dem Verkauf zustehenden Forderungen an uns ab. Kommt der Käufer mit seinen Zahlungen in Verzug, so können wir jederzeit die Herausgabe der Ware verlangen, ohne vom Vertrag zurückzutreten.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Belehrung nach Fernabsatzgesetz
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Die nachfolgende Belehrung gilt nur für Verträge, die zwischen Growheads und Verbrauchern durch Katalogbestellung, Internetbestellung oder durch sonstige Fern-Kommunikationsmittel zustande gekommen sind. Sie ist auf Verbraucher innerhalb der EG beschränkt.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
1. Wesentliche Merkmale der Ware
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Die wesentlichen Merkmale der Ware entnehmen Sie bitte den Erläuterungen im Katalog oder unserer Web-Site. Die Angebote in unserem Katalog und auf unserer Web-Site sind freibleibend. Bestellungen an uns verstehen sich als bindende Angebote. Diese kann Growheads innerhalb einer Frist von 14 Tagen ab Zugang der Bestellung durch eine Auftragsbestätigung oder durch Zusendung der Ware annehmen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
2. Vorbehalt
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Sollten nicht alle bestellten Artikel lieferbar sein, behalten wir uns Teillieferungen vor, soweit diese dem Kunden zumutbar sind. Einzelne Artikel können von den Abbildungen und Beschreibungen im Katalog und auf der Webseite eventuell abweichen. Dies gilt natürlich im Besonderen für Waren, die in Handarbeit gefertigt werden. Wir behalten uns daher vor, unter Umständen in Qualität und Preis gleichwertige Waren zu liefern.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
3. Preise und Steuern
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Die Preise der einzelnen Artikel inklusive Mehrwertsteuer können Sie dem Katalog bzw. unserer Website entnehmen. Die Preise verlieren ihre Gültigkeit mit Erscheinen eines neuen Kataloges.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
4. Alle Preise sind unter dem Vorbehalt von Fehlern oder Preisschwankungen. Sollte es zu einer Preisänderung kommen, so kann der Käufer von seinem Rückgaberecht gebrauch machen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
5. Gewährleistungsfrist
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Es gilt die gesetzliche Gewährleistungsfrist von 24 (vierundzwanzig) Monaten. Im Einzelfall können längere Fristen gelten, wenn diese vom Hersteller gewährt werden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
6. Rückgaberecht / Widerrufsrecht
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Dem Kunden steht ein 14-tägiges Rückgaberecht zu.
|
||||
Die Frist hierzu beginnt mit dem Eingang der Ware beim Kunden und ist gewahrt durch die rechtzeitige Absendung des Widerrufs an Growheads. Ausgenommen davon sind Lebensmittel und andere verderbliche Waren, sowie Spezialanfertigungen, oder Waren, die extra auf Wunsch des Kunden bestellt wurden. Die Rückgabe hat durch Rücksendung der Ware innerhalb der Frist zu erfolgen. Kann die Ware nicht versandt werden, so ist innerhalb der Frist ein Rücknahmeverlangen durch Brief, Postkarte, Email oder einen anderen dauerhaften Datenträger an uns zu richten. Zur Fristwahrung genügt die rechtzeitige Absendung an die unter 7) genannte Unternehmensanschrift. Der Widerruf bedarf keiner Begründung. Der Kaufpreis sowie eventuelle Liefer- und Versandkosten werden nach Eingang der Ware bei uns zurückerstattet. Entscheidend ist der Wert der zurückgesandten Ware zum Zeitpunkt des Kaufs, nicht der Wert der gesamten Bestellung. Growheads kann in der Regel eine Abholung bei Ihnen veranlassen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
7. Name und Anschrift des Unternehmens, Beanstandungen, Ladungen
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Growheads<br />
|
||||
Trachenberger Straße 14<br />
|
||||
01129 Dresden
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
8. Erfüllungsort und Gerichtsstand
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Erfüllungsort und Gerichtsstand für alle Ansprüche ist Dresden, soweit nicht zwingende gesetzliche Vorschriften dem entgegenstehen.
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
|
||||
return <LegalPage title="Allgemeine Geschäftsbedingungen" content={content} />;
|
||||
};
|
||||
|
||||
export default AGB;
|
||||
560
src/pages/AdminPage.js
Normal file
560
src/pages/AdminPage.js
Normal file
@@ -0,0 +1,560 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Paper,
|
||||
Box,
|
||||
Divider,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Tabs,
|
||||
Tab
|
||||
} from '@mui/material';
|
||||
import { Navigate, Link } from 'react-router-dom';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ADMIN_COLORS, getAdminStyles } from '../theme/adminColors.js';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
class AdminPage extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
users: {},
|
||||
user: null,
|
||||
stats: null,
|
||||
loading: true,
|
||||
redirect: false
|
||||
};
|
||||
}
|
||||
|
||||
checkUserLoggedIn = () => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (!storedUser) {
|
||||
this.setState({ redirect: true, user: null });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userData = JSON.parse(storedUser);
|
||||
if (!userData) {
|
||||
this.setState({ redirect: true, user: null });
|
||||
} else if (!this.state.user) {
|
||||
// Only update user if it's not already set
|
||||
this.setState({ user: userData, loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing user from sessionStorage:', error);
|
||||
this.setState({ redirect: true, user: null });
|
||||
}
|
||||
|
||||
// Once loading is complete
|
||||
if (this.state.loading) {
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleStorageChange = (e) => {
|
||||
if (e.key === 'user' && !e.newValue) {
|
||||
// User was removed from sessionStorage in another tab
|
||||
this.setState({ redirect: true, user: null });
|
||||
}
|
||||
}
|
||||
handleCartUpdated = (id,user,cart,id2) => {
|
||||
const users = this.state.users;
|
||||
if(user && user.email) id = user.email;
|
||||
if(id2) id=id2;
|
||||
if(cart) users[id] = cart;
|
||||
if(!users[id]) delete users[id];
|
||||
console.log(users);
|
||||
this.setState({ users });
|
||||
}
|
||||
|
||||
|
||||
|
||||
componentDidMount() {
|
||||
this.loadInitialData();
|
||||
this.addSocketListeners();
|
||||
this.checkUserLoggedIn();
|
||||
// Set up interval to regularly check login status
|
||||
this.checkLoginInterval = setInterval(this.checkUserLoggedIn, 1000);
|
||||
// Add storage event listener to detect when user logs out in other tabs
|
||||
window.addEventListener('storage', this.handleStorageChange);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected) {
|
||||
// Socket just connected, add listeners and reload data
|
||||
this.addSocketListeners();
|
||||
this.loadInitialData();
|
||||
} else if (wasConnected && !isNowConnected) {
|
||||
// Socket just disconnected, remove listeners
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.removeSocketListeners();
|
||||
// Clear interval and remove event listeners
|
||||
if (this.checkLoginInterval) {
|
||||
clearInterval(this.checkLoginInterval);
|
||||
}
|
||||
window.removeEventListener('storage', this.handleStorageChange);
|
||||
}
|
||||
|
||||
loadInitialData = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('getStats', (stats) => {
|
||||
console.log('AdminPage: getStats', JSON.stringify(stats,null,2));
|
||||
this.setState({stats: stats});
|
||||
});
|
||||
this.props.socket.emit('initialCarts', (carts) => {
|
||||
console.log('AdminPage: initialCarts', carts);
|
||||
if(carts && carts.success == true)
|
||||
{
|
||||
const users = {};
|
||||
for(const item of carts.carts){
|
||||
const user = {email:item.email};
|
||||
let id = item.clientUrlId || item.socketId;
|
||||
const cart = item.cart;
|
||||
if(user && user.email) id = user.email;
|
||||
if(cart) users[id] = cart;
|
||||
}
|
||||
this.setState({ users: users });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
formatPrice = (price) => {
|
||||
return typeof price === 'number'
|
||||
? `€${price.toFixed(2)}`
|
||||
: price;
|
||||
}
|
||||
|
||||
prepareChartData = () => {
|
||||
if (!this.state.stats || !this.state.stats.data || !this.state.stats.data.last30Days) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dailyData = this.state.stats.data.last30Days.dailyData || [];
|
||||
|
||||
// Sort data by date to ensure proper chronological order
|
||||
const sortedData = [...dailyData].sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
|
||||
const labels = sortedData.map(item => {
|
||||
const date = new Date(item.date);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
});
|
||||
|
||||
const socketConnections = sortedData.map(item => item.socket_connections || 0);
|
||||
const productViewCalls = sortedData.map(item => item.get_product_view_calls || 0);
|
||||
|
||||
return {
|
||||
labels,
|
||||
socketConnections,
|
||||
productViewCalls
|
||||
};
|
||||
}
|
||||
|
||||
getSocketConnectionsChartData = () => {
|
||||
const data = this.prepareChartData();
|
||||
if (!data) return null;
|
||||
|
||||
return {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Site Visits',
|
||||
data: data.socketConnections,
|
||||
borderColor: '#8be9fd', // terminal.ansiCyan
|
||||
backgroundColor: 'rgba(139, 233, 253, 0.2)', // terminal.ansiCyan with transparency
|
||||
tension: 0.1,
|
||||
pointBackgroundColor: '#8be9fd',
|
||||
pointBorderColor: '#8be9fd',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
getProductViewCallsChartData = () => {
|
||||
const data = this.prepareChartData();
|
||||
if (!data) return null;
|
||||
|
||||
return {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Product Detail Page Visits',
|
||||
data: data.productViewCalls,
|
||||
backgroundColor: 'rgba(255, 121, 198, 0.2)', // terminal.ansiMagenta with transparency
|
||||
borderColor: '#ff79c6', // terminal.ansiMagenta
|
||||
borderWidth: 2,
|
||||
tension: 0.1,
|
||||
pointBackgroundColor: '#ff79c6',
|
||||
pointBorderColor: '#ff79c6',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
getChartOptions = (title) => ({
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
font: {
|
||||
family: ADMIN_COLORS.fontFamily
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: title,
|
||||
color: ADMIN_COLORS.primary,
|
||||
font: {
|
||||
family: ADMIN_COLORS.fontFamily,
|
||||
weight: 'bold'
|
||||
}
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
font: {
|
||||
family: ADMIN_COLORS.fontFamily
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: ADMIN_COLORS.border
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
font: {
|
||||
family: ADMIN_COLORS.fontFamily
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: ADMIN_COLORS.border
|
||||
}
|
||||
},
|
||||
},
|
||||
backgroundColor: ADMIN_COLORS.surfaceBackground,
|
||||
color: ADMIN_COLORS.primaryText
|
||||
})
|
||||
|
||||
render() {
|
||||
const { users } = this.state;
|
||||
|
||||
if (this.state.redirect || (!this.state.loading && !this.state.user)) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
// Check if current user is admin
|
||||
if (this.state.user && !this.state.user.admin) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
const hasUsers = Object.keys(users).length > 0;
|
||||
|
||||
const socketConnectionsData = this.getSocketConnectionsChartData();
|
||||
const productViewCallsData = this.getProductViewCallsChartData();
|
||||
|
||||
const styles = getAdminStyles();
|
||||
|
||||
return (
|
||||
<Box sx={styles.pageContainer}>
|
||||
<Container
|
||||
maxWidth="lg"
|
||||
sx={{
|
||||
py: 6
|
||||
}}
|
||||
>
|
||||
{/* Admin Navigation Tabs */}
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
mb: 3,
|
||||
...styles.tabBar
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={0}
|
||||
indicatorColor="primary"
|
||||
sx={{
|
||||
px: 2,
|
||||
'& .MuiTabs-indicator': {
|
||||
backgroundColor: ADMIN_COLORS.primary
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
label="Dashboard"
|
||||
component={Link}
|
||||
to="/admin"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: ADMIN_COLORS.primary,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Users"
|
||||
component={Link}
|
||||
to="/admin/users"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
'&:hover': {
|
||||
color: ADMIN_COLORS.primaryBright
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Server Logs"
|
||||
component={Link}
|
||||
to="/admin/logs"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
'&:hover': {
|
||||
color: ADMIN_COLORS.primaryBright
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
{/* Analytics Charts Section */}
|
||||
{(socketConnectionsData || productViewCallsData) && (
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
...styles.contentPaper
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
gutterBottom
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
...styles.primaryHeading
|
||||
}}
|
||||
>
|
||||
<BarChartIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
|
||||
30-Day Analytics
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3} sx={{ mt: 1 }}>
|
||||
{socketConnectionsData && (
|
||||
<Grid size={{ xs: 12, lg: 6 }}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={styles.card}
|
||||
>
|
||||
<CardContent sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<Line
|
||||
data={socketConnectionsData}
|
||||
options={this.getChartOptions('Site Visits')}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{productViewCallsData && (
|
||||
<Grid size={{ xs: 12, lg: 6 }}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={styles.card}
|
||||
>
|
||||
<CardContent sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<Line
|
||||
data={productViewCallsData}
|
||||
options={this.getChartOptions('Product Detail Page Visits')}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
...styles.contentPaper
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
gutterBottom
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
...styles.primaryHeading
|
||||
}}
|
||||
>
|
||||
<ShoppingCartIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
|
||||
Active User Carts
|
||||
</Typography>
|
||||
|
||||
{!hasUsers && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
mt: 2,
|
||||
...styles.secondaryText
|
||||
}}
|
||||
>
|
||||
No active user carts at the moment.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{hasUsers && (
|
||||
<Grid container spacing={3} sx={{ mt: 1 }}>
|
||||
{Object.keys(users).map((user, i) => {
|
||||
const cartItems = Object.keys(users[user]);
|
||||
const totalValue = cartItems.reduce((total, item) => {
|
||||
return total + (parseFloat(users[user][item].price) || 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Grid size={{ xs: 12, md: 6 }} key={i}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={styles.card}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
...styles.primaryHeading
|
||||
}}
|
||||
>
|
||||
<PersonIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
|
||||
{user}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ mb: 2, borderColor: ADMIN_COLORS.border }} />
|
||||
|
||||
<List disablePadding>
|
||||
{cartItems.map((item, j) => (
|
||||
<ListItem
|
||||
key={j}
|
||||
divider={j < cartItems.length - 1}
|
||||
sx={{
|
||||
py: 1,
|
||||
borderBottom: j < cartItems.length - 1 ? `1px solid ${ADMIN_COLORS.border}` : 'none'
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={users[user][item].name}
|
||||
secondary={users[user][item].quantity+' x '+this.formatPrice(users[user][item].price)}
|
||||
primaryTypographyProps={{
|
||||
fontWeight: 'medium',
|
||||
...styles.primaryText
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
color: ADMIN_COLORS.warning,
|
||||
fontWeight: 'bold',
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ mt: 2, textAlign: 'right' }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: ADMIN_COLORS.primaryBright,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
>
|
||||
Total: {this.formatPrice(totalValue)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AdminPage;
|
||||
35
src/pages/Batteriegesetzhinweise.js
Normal file
35
src/pages/Batteriegesetzhinweise.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
import LegalPage from './LegalPage.js';
|
||||
|
||||
const Batteriegesetzhinweise = () => {
|
||||
const content = (
|
||||
<>
|
||||
<Typography variant="body1" paragraph>
|
||||
Im Zusammenhang mit dem Vertrieb von Batterien oder mit der Lieferung von Geräten, die Batterien enthalten, sind wir verpflichtet, Sie auf folgendes hinzuweisen:
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Sie sind zur Rückgabe gebrauchter Batterien als Endnutzer gesetzlich verpflichtet. Sie können Altbatterien, die wir als Neubatterien im Sortiment führen oder geführt haben, unentgeltlich an unserem Versandlager (Versandadresse) zurückgeben.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Die auf den Batterien abgebildeten Symbole haben folgende Bedeutung:
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Das Symbol der durchgekreuzten Mülltonne bedeutet, dass die Batterie nicht in den Hausmüll gegeben werden darf.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" sx={{ ml: 2 }} paragraph>
|
||||
Pb = Batterie enthält mehr als 0,004 Masseprozent Blei<br />
|
||||
Cd = Batterie enthält mehr als 0,002 Masseprozent Cadmium<br />
|
||||
Hg = Batterie enthält mehr als 0,0005 Masseprozent Quecksilber.
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
|
||||
return <LegalPage title="Batteriegesetzhinweise" content={content} />;
|
||||
};
|
||||
|
||||
export default Batteriegesetzhinweise;
|
||||
149
src/pages/Datenschutz.js
Normal file
149
src/pages/Datenschutz.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import LegalPage from './LegalPage.js';
|
||||
|
||||
const Datenschutz = () => {
|
||||
const content = (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Verantwortlich im Sinne der Datenschutzgesetzes:
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Growheads<br />
|
||||
Trachenberger Straße 14<br />
|
||||
01129 Dresden
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Soweit nachstehend keine anderen Angaben gemacht werden, ist die Bereitstellung Ihrer personenbezogenen Daten weder gesetzlich oder vertraglich vorgeschrieben, noch für einen Vertragsabschluss erforderlich. Sie sind zur Bereitstellung der Daten nicht verpflichtet. Eine Nichtbereitstellung hat keine Folgen. Dies gilt nur soweit bei den nachfolgenden Verarbeitungsvorgängen keine anderweitige Angabe gemacht wird. "Personenbezogene Daten" sind alle Informationen, die sich auf eine identifizierte oder identifizierbare natürliche Person beziehen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Auskunft, Löschung, Sperrung
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Zu jedem Zeitpunkt können Sie sich über die personenbezogenen Daten, deren Herkunft und Empfänger und den Nutzen der Datenverarbeitung informieren und unentgeltlich eine Korrektur, Sperrung oder Löschung dieser Daten verlangen. Bitte nutzen Sie dafür die im Footer der Seite oder im Impressum angegebenen Kontaktmöglichkeiten. Für weitere Fragen zum Thema stehen wir Ihnen ebenfalls jederzeit zur Verfügung. Bitte beachten Sie, das wir Rechnungsdaten, Bankdaten und Daten die zu einem Versanddienstleister gegangen sind nicht löschen dürfen und nicht werden. Daten die gelöscht werden können sind: Kundenkonto auf dem Webserver, so wie in der Warenwirtschaft und Emails, die nicht unmittelbar zu einer Bestellung gehören.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Server-Logfiles
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Sie können unsere Webseiten besuchen, ohne Angaben zu Ihrer Person zu machen. Es werden bei jedem Zugriff auf unsere Website Nutzungsdaten durch Ihren Internetbrowser übermittelt und in Protokolldaten (Server-Logfiles) gespeichert. Zu diesen gespeicherten Daten gehören z.B. Name der aufgerufenen Seite, Datum und Uhrzeit des Abrufs, übertragene Datenmenge und der anfragende Provider. Diese Daten dienen ausschließlich der Gewährleistung eines störungsfreien Betriebs unserer Website und zur Verbesserung unseres Angebotes. Diese Daten sind nicht personenbezogen. Es erfolgt keine Zusammenführung dieser Daten mit anderen Datenquellen. Wenn uns konkrete Anhaltspunkte für eine rechtswidrige Nutzung bekannt werden behalten wir uns das Recht vor, diese Daten nachträglich zu überprüfen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Kundenkonto
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Bei der Eröffnung eines Kundenkontos erheben wir Ihre personenbezogenen Daten in dem dort angegeben Umfang. Die Datenverarbeitung dient dem Zweck, Ihr Einkaufserlebnis zu verbessern und die Bestellabwicklung zu vereinfachen. Die Verarbeitung erfolgt auf Grundlage des Art. 6 (1) lit. a DSGVO mit Ihrer Einwilligung. Sie können Ihre Einwilligung jederzeit durch Mitteilung an uns widerrufen, ohne dass die Rechtmäßigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten Verarbeitung berührt wird. Ihr Kundenkonto wird anschließend gelöscht.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Anmeldung mit Google (Google Single Sign-On)
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Wir bieten Ihnen die Möglichkeit, sich für Ihr Kundenkonto mit Ihrem Google-Konto anzumelden. Wenn Sie die Funktion "Mit Google anmelden" nutzen, erfolgt die Authentifizierung über den Dienst Google Single Sign-On. Dabei können Cookies von Google auf Ihrem Endgerät gespeichert werden, die für den Anmeldeprozess und die Authentifizierung erforderlich sind. Im Rahmen der Google-Anmeldung erhalten wir von Google bestimmte personenbezogene Daten zur Verifizierung Ihrer Identität. Insbesondere übermittelt Google an uns Ihren Namen, Ihre E-Mail-Adresse sowie – falls in Ihrem Google-Konto hinterlegt – Ihr Profilbild. Diese Informationen werden von Google bereitgestellt, sobald Sie sich mit Ihrem Google-Konto bei unserem Online-Shop anmelden. Google kann als Drittanbieter auf diese Daten zugreifen und sie verarbeiten; hierbei kann es auch zu einer Datenübermittlung in die USA kommen. Wir haben mit Google Standarddatenschutzklauseln nach Art. 46 Abs. 2 lit. c DSGVO abgeschlossen, um ein angemessenes Datenschutzniveau bei der Übermittlung Ihrer Daten sicherzustellen. Weitere Details zur Datenverarbeitung durch Google finden Sie in der Google-Datenschutzerklärung (unter <a href="https://policies.google.com/privacy?hl=de" target="_blank" rel="noopener noreferrer">policies.google.com/privacy?hl=de</a>).
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
<strong>Rechtsgrundlagen:</strong> Die Verarbeitung der Daten im Zusammenhang mit der Google-Anmeldung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO (Durchführung vorvertraglicher Maßnahmen und Vertragserfüllung, z. B. zur Erstellung und Nutzung Ihres Kundenkontos) sowie Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse unsererseits, Ihnen eine schnelle und komfortable Anmeldemöglichkeit bereitzustellen).
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
<strong>Freiwillige Nutzung:</strong> Die Nutzung der "Mit Google anmelden"-Funktion ist freiwillig. Sie können unseren Online-Shop und Ihr Kundenkonto selbstverständlich auch ohne Google SSO nutzen, indem Sie sich regulär mit E-Mail-Adresse und Passwort registrieren bzw. anmelden. Wenn Sie sich für die Google-Anmeldung entscheiden, können Sie diese Verbindung jederzeit wieder aufheben, indem Sie die Verknüpfung in Ihren Google-Konto-Einstellungen trennen.
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
<strong>Ihre Rechte:</strong> Bezüglich der über Google SSO verarbeiteten personenbezogenen Daten stehen Ihnen die gesetzlichen Betroffenenrechte zu. Insbesondere haben Sie das Recht, Auskunft über die zu Ihrer Person gespeicherten Daten zu erhalten (Art. 15 DSGVO), Berichtigung unrichtiger Daten (Art. 16 DSGVO) oder Löschung Ihrer Daten zu verlangen (Art. 17 DSGVO). Ferner haben Sie das Recht auf Einschränkung der Verarbeitung Ihrer Daten (Art. 18 DSGVO) und ein Recht auf Datenübertragbarkeit (Art. 20 DSGVO). Soweit wir die Verarbeitung auf unser berechtigtes Interesse stützen, können Sie Widerspruch gegen die Verarbeitung einlegen (Art. 21 DSGVO). Zudem können Sie sich jederzeit mit einer Beschwerde an die zuständige Datenschutzaufsichtsbehörde wenden. Ihre bereits bestehenden Rechte und Wahlmöglichkeiten aus der übrigen Datenschutzerklärung gelten selbstverständlich auch für die Nutzung von Google Anmelden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Erhebung, Verarbeitung und Nutzung personenbezogener Daten bei Bestellungen
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Bei der Bestellung erheben und verwenden wir Ihre personenbezogenen Daten nur, soweit dies zur Erfüllung und Abwicklung Ihrer Bestellung sowie zur Bearbeitung Ihrer Anfragen erforderlich ist. Die Bereitstellung der Daten ist für den Vertragsschluss erforderlich. Eine Nichtbereitstellung hat zur Folge, dass kein Vertrag geschlossen werden kann. Die Verarbeitung erfolgt auf Grundlage des Art. 6 (1) lit. b DSGVO und ist für die Erfüllung eines Vertrags mit Ihnen erforderlich. Eine Weitergabe Ihrer Daten an Dritte ohne Ihre ausdrückliche Einwilligung erfolgt nicht. Ausgenommen hiervon sind lediglich unsere Dienstleistungspartner, die wir zur Abwicklung des Vertragsverhältnisses benötigen oder Dienstleister, derer wir uns im Rahmen einer Auftragsverarbeitung bedienen. Neben den in den jeweiligen Klauseln dieser Datenschutzerklärung benannten Empfängern sind dies beispielsweise Empfänger folgender Kategorien: Versanddienstleister, Zahlungsdienstleister, Warenwirtschaftsdienstleister, Diensteanbieter für die Bestellabwicklung, Webhoster, IT-Dienstleister und Dropshipping-Händler. In allen Fällen beachten wir strikt die gesetzlichen Vorgaben. Der Umfang der Datenübermittlung beschränkt sich auf ein Mindestmaß.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Verwendung der E-Mail-Adresse für die Zusendung von Newslettern
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Wir nutzen Ihre E-Mail-Adresse unabhängig von der Vertragsabwicklung ausschließlich für eigene Werbezwecke zum Newsletterversand, sofern Sie dem ausdrücklich zugestimmt haben. Die Verarbeitung erfolgt auf Grundlage des Art. 6 (1) lit. a DSGVO mit Ihrer Einwilligung. Sie können die Einwilligung jederzeit widerrufen, ohne dass die Rechtmäßigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten Verarbeitung berührt wird. Sie können dazu den Newsletter jederzeit unter Nutzung des entsprechenden Links im Newsletter oder durch Mitteilung an uns abbestellen. Ihre E-Mail-Adresse wird danach aus dem Verteiler entfernt. Ihre Daten werden dabei an einen Dienstleister für E-Mail-Marketing im Rahmen einer Auftragsverarbeitung weitergegeben. Eine Weitergabe an sonstige Dritte erfolgt nicht. Ihre Daten werden an ein Drittland übermittelt, für welches ein Angemessenheitsbeschluss der Europäischen Kommission vorhanden ist.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Einsatz eines KI-Chatbots (OpenAI API)
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Wir verwenden auf unserer Website einen KI-gestützten Chatbot, der über die Programmierschnittstelle (API) des Anbieters OpenAI betrieben wird. Der Chatbot dient dazu, Anfragen von Besuchern effizient und automatisiert zu beantworten und somit eine Support-Funktion bereitzustellen. Wenn Sie den Chatbot nutzen, werden Ihre Eingaben vom System verarbeitet, um passende Antworten zu generieren. Die Verarbeitung erfolgt anonymisiert – es werden keine IP-Adressen oder sonstige personenbezogene Daten (wie Name oder Kontaktdaten) erfasst oder gespeichert.
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Rechtsgrundlage für den Einsatz des Chatbots ist unser berechtigtes Interesse nach Art. 6 Abs. 1 lit. f DSGVO. Dieses Interesse liegt in der Bereitstellung eines effektiven Besucher-Supports sowie in der Verbesserung der Nutzererfahrung auf unserer Website.
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Empfänger der Chat-Daten ist OpenAI (OpenAI OpCo, LLC) als technischer Dienstleister. OpenAI verarbeitet die übermittelten Chat-Inhalte auf seinen Servern ausschließlich zum Zweck der Antwortgenerierung. OpenAI handelt hierbei als Auftragsverarbeiter gemäß Art. 28 DSGVO und verwendet die Daten nicht für eigene Zwecke. Wir haben mit OpenAI einen Vertrag zur Auftragsverarbeitung geschlossen, der die EU-Standardvertragsklauseln als geeignete Garantien für den Datenschutz umfasst. OpenAI hat seinen Hauptsitz in den USA; durch die Vereinbarung der Standardvertragsklauseln wird sichergestellt, dass bei der Übermittlung Ihrer Daten ein der Europäischen Union entsprechendes Datenschutzniveau gewährleistet ist.
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Wir speichern Ihre Chat-Anfragen nur so lange, wie es für die Bearbeitung und Beantwortung erforderlich ist. Sobald Ihr Anliegen abgeschlossen ist, werden die Chat-Verläufe zeitnah gelöscht beziehungsweise anonymisiert. OpenAI bewahrt die verarbeiteten Chat-Daten nach eigenen Angaben nur vorübergehend auf und löscht sie automatisiert spätestens nach 30 Tagen.
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Die Nutzung des Chatbots ist freiwillig. Wenn Sie den Chatbot nicht verwenden, findet keine Datenübermittlung an OpenAI statt. Bitte geben Sie im Chat keine sensiblen personenbezogenen Daten ein.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Cookies
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Unsere Website setzt Cookies in folgenden Fällen ein:
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph component="div" style={{ paddingLeft: '20px' }}>
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong>1. Zahlungsprozess:</strong> Bei Kreditkartenzahlungen oder Sofortüberweisungen (z. B. Klarna Sofort) werden technisch notwendige Cookies verwendet. Diese enthalten eine charakteristische Zeichenfolge, die eine eindeutige Identifizierung des Browsers ermöglicht. Die Cookies werden vom Zahlungsdienstleister Stripe gesetzt und sind für die sichere und reibungslose Abwicklung der Zahlungen zwingend erforderlich. Ohne diese Cookies ist eine Bestellung mit diesen Zahlungsarten nicht möglich. Die Verarbeitung erfolgt auf Grundlage des Art. 6 (1) lit. b DSGVO zur Vertragserfüllung.
|
||||
</div>
|
||||
<div>
|
||||
<strong>2. Google Single Sign-On (SSO):</strong> Bei Nutzung der Google-Anmeldung werden Cookies durch Google gesetzt, die für den Anmeldevorgang und die Authentifizierung erforderlich sind. Diese Cookies ermöglichen es Ihnen, sich bequem mit Ihrem Google-Konto anmelden zu können, ohne sich jedes Mal neu anmelden zu müssen. Die Verarbeitung erfolgt auf Grundlage von Art. 6 (1) lit. b DSGVO (Vertragserfüllung) und Art. 6 (1) lit. f DSGVO (berechtigtes Interesse an einer benutzerfreundlichen Anmeldung).
|
||||
</div>
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Für andere Zahlungsarten – Lastschrift, Abholung oder Nachnahme – werden keine zusätzlichen Cookies verwendet, sofern Sie nicht die Google-Anmeldung nutzen.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Stripe (Zahlungsabwicklung)
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Wir nutzen auf unserer Website den Zahlungsdienstleister Stripe zur Abwicklung von Zahlungen. Anbieter des Dienstes ist Stripe, Inc., 510 Townsend Street, San Francisco, CA 94103, USA (für Kunden im Europäischen Wirtschaftsraum: Stripe Payments Europe Ltd., 1 Grand Canal Street Lower, Dublin, Irland). In diesem Zusammenhang werden personenbezogene Daten, die für die Zahlungsabwicklung erforderlich sind, an Stripe übermittelt – insbesondere Ihr Name, Ihre E-Mail-Adresse, Rechnungsanschrift, Zahlungsinformationen (z. B. Kreditkartendaten) sowie die IP-Adresse. Die Datenverarbeitung erfolgt zum Zweck der Zahlungsabwicklung; Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, da sie der Erfüllung eines Vertrags mit Ihnen dient.
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Stripe verarbeitet bestimmte Daten außerdem als eigenständig Verantwortlicher, beispielsweise zur Erfüllung gesetzlicher Pflichten (etwa Geldwäsche-Prävention) und zur Betrugsabwehr. Daneben haben wir mit Stripe einen Auftragsverarbeitungsvertrag gemäß Art. 28 DSGVO geschlossen; im Rahmen dieser Vereinbarung handelt Stripe bei der Zahlungsabwicklung ausschließlich nach unserer Weisung.
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Soweit Stripe personenbezogene Daten außerhalb der EU, insbesondere in den USA, verarbeitet, geschieht dies unter Einhaltung geeigneter Garantien. Stripe setzt hierfür die EU-Standardvertragsklauseln nach Art. 46 DSGVO ein, um ein angemessenes Datenschutzniveau sicherzustellen. Wir weisen jedoch darauf hin, dass die USA datenschutzrechtlich als Drittland mit möglicherweise unzureichendem Datenschutzniveau gelten. Weitere Informationen finden Sie in der Datenschutzerklärung von Stripe unter <a href="https://stripe.com/de/privacy" target="_blank" rel="noopener noreferrer">https://stripe.com/de/privacy</a>.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Dauer der Speicherung
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Nach vollständiger Vertragsabwicklung werden die Daten zunächst für die Dauer der Gewährleistungsfrist, danach unter Berücksichtigung gesetzlicher, insbesondere steuer- und handelsrechtlicher Aufbewahrungsfristen gespeichert und dann nach Fristablauf gelöscht, sofern Sie der weitergehenden Verarbeitung und Nutzung nicht zugestimmt haben.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Rechte der betroffenen Person
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Ihnen stehen bei Vorliegen der gesetzlichen Voraussetzungen folgende Rechte nach Art. 15 bis 20 DSGVO zu: Recht auf Auskunft, auf Berichtigung, auf Löschung, auf Einschränkung der Verarbeitung, auf Datenübertragbarkeit.
|
||||
Außerdem steht Ihnen nach Art. 21 (1) DSGVO ein Widerspruchsrecht gegen die Verarbeitungen zu, die auf Art. 6 (1) f DSGVO beruhen, sowie gegen die Verarbeitung zum Zwecke von Direktwerbung. Kontaktieren Sie uns auf Wunsch. Die Kontaktdaten finden Sie in unserem Impressum.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Beschwerderecht bei der Aufsichtsbehörde
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Sie haben gemäß Art. 77 DSGVO das Recht, sich bei der Aufsichtsbehörde zu beschweren, wenn Sie der Ansicht sind, dass die Verarbeitung Ihrer personenbezogenen Daten nicht rechtmäßig erfolgt.
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
|
||||
return <LegalPage title="Datenschutzerklärung" content={content} />;
|
||||
};
|
||||
|
||||
export default Datenschutz;
|
||||
525
src/pages/GrowTentKonfigurator.js
Normal file
525
src/pages/GrowTentKonfigurator.js
Normal file
@@ -0,0 +1,525 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ShoppingCart as ShoppingCartIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { TentShapeSelector, ProductSelector, ExtrasSelector } from '../components/configurator/index.js';
|
||||
import { tentShapes, tentSizes, lightTypes, ventilationTypes, extras } from '../data/configuratorData.js';
|
||||
|
||||
class GrowTentKonfigurator extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Try to restore state from window object
|
||||
const savedState = window.growTentKonfiguratorState;
|
||||
|
||||
this.state = {
|
||||
selectedTentShape: savedState?.selectedTentShape || '80x80',
|
||||
selectedTentSize: savedState?.selectedTentSize || 'tent_80x80x160',
|
||||
selectedLightType: savedState?.selectedLightType || 'led_quantum_board',
|
||||
selectedVentilationType: savedState?.selectedVentilationType || 'premium_ventilation',
|
||||
selectedExtras: savedState?.selectedExtras || [],
|
||||
totalPrice: savedState?.totalPrice || 0
|
||||
};
|
||||
|
||||
this.handleTentShapeSelect = this.handleTentShapeSelect.bind(this);
|
||||
this.handleTentSizeSelect = this.handleTentSizeSelect.bind(this);
|
||||
this.handleLightTypeSelect = this.handleLightTypeSelect.bind(this);
|
||||
this.handleVentilationSelect = this.handleVentilationSelect.bind(this);
|
||||
this.handleExtraToggle = this.handleExtraToggle.bind(this);
|
||||
this.calculateTotalPrice = this.calculateTotalPrice.bind(this);
|
||||
this.saveStateToWindow = this.saveStateToWindow.bind(this);
|
||||
}
|
||||
|
||||
saveStateToWindow() {
|
||||
// Save current state to window object for backup
|
||||
window.growTentKonfiguratorState = {
|
||||
selectedTentShape: this.state.selectedTentShape,
|
||||
selectedTentSize: this.state.selectedTentSize,
|
||||
selectedLightType: this.state.selectedLightType,
|
||||
selectedVentilationType: this.state.selectedVentilationType,
|
||||
selectedExtras: this.state.selectedExtras,
|
||||
totalPrice: this.state.totalPrice,
|
||||
timestamp: Date.now() // Add timestamp for debugging
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// @note Calculate initial total price with preselected products
|
||||
this.calculateTotalPrice();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
// Reset tent size selection if shape changes
|
||||
if (prevState.selectedTentShape !== this.state.selectedTentShape && this.state.selectedTentShape !== prevState.selectedTentShape) {
|
||||
this.setState({ selectedTentSize: '' });
|
||||
}
|
||||
|
||||
// Recalculate total price when selections change
|
||||
if (
|
||||
prevState.selectedTentSize !== this.state.selectedTentSize ||
|
||||
prevState.selectedLightType !== this.state.selectedLightType ||
|
||||
prevState.selectedVentilationType !== this.state.selectedVentilationType ||
|
||||
prevState.selectedExtras !== this.state.selectedExtras
|
||||
) {
|
||||
this.calculateTotalPrice();
|
||||
}
|
||||
|
||||
// Save state to window object whenever selections change
|
||||
if (
|
||||
prevState.selectedTentShape !== this.state.selectedTentShape ||
|
||||
prevState.selectedTentSize !== this.state.selectedTentSize ||
|
||||
prevState.selectedLightType !== this.state.selectedLightType ||
|
||||
prevState.selectedVentilationType !== this.state.selectedVentilationType ||
|
||||
prevState.selectedExtras !== this.state.selectedExtras ||
|
||||
prevState.totalPrice !== this.state.totalPrice
|
||||
) {
|
||||
this.saveStateToWindow();
|
||||
}
|
||||
}
|
||||
|
||||
handleTentShapeSelect(shapeId) {
|
||||
this.setState({ selectedTentShape: shapeId });
|
||||
}
|
||||
|
||||
handleTentSizeSelect(tentId) {
|
||||
this.setState({ selectedTentSize: tentId });
|
||||
}
|
||||
|
||||
handleLightTypeSelect(lightId) {
|
||||
this.setState({ selectedLightType: lightId });
|
||||
}
|
||||
|
||||
handleVentilationSelect(ventilationId) {
|
||||
this.setState({ selectedVentilationType: ventilationId });
|
||||
}
|
||||
|
||||
handleExtraToggle(extraId) {
|
||||
const { selectedExtras } = this.state;
|
||||
const newSelectedExtras = selectedExtras.includes(extraId)
|
||||
? selectedExtras.filter(id => id !== extraId)
|
||||
: [...selectedExtras, extraId];
|
||||
|
||||
this.setState({ selectedExtras: newSelectedExtras });
|
||||
}
|
||||
|
||||
|
||||
|
||||
calculateTotalPrice() {
|
||||
let total = 0;
|
||||
const { selectedTentSize, selectedLightType, selectedVentilationType, selectedExtras } = this.state;
|
||||
let itemCount = 0;
|
||||
|
||||
// Add tent price
|
||||
if (selectedTentSize) {
|
||||
const tent = tentSizes.find(t => t.id === selectedTentSize);
|
||||
if (tent) {
|
||||
total += tent.price;
|
||||
itemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add light price
|
||||
if (selectedLightType) {
|
||||
const light = lightTypes.find(l => l.id === selectedLightType);
|
||||
if (light) {
|
||||
total += light.price;
|
||||
itemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add ventilation price
|
||||
if (selectedVentilationType) {
|
||||
const ventilation = ventilationTypes.find(v => v.id === selectedVentilationType);
|
||||
if (ventilation) {
|
||||
total += ventilation.price;
|
||||
itemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add extras prices
|
||||
selectedExtras.forEach(extraId => {
|
||||
const extra = extras.find(e => e.id === extraId);
|
||||
if (extra) {
|
||||
total += extra.price;
|
||||
itemCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Apply bundle discount
|
||||
let discountPercentage = 0;
|
||||
if (itemCount >= 3) discountPercentage = 15; // 15% for 3+ items
|
||||
if (itemCount >= 5) discountPercentage = 24; // 24% for 5+ items
|
||||
if (itemCount >= 7) discountPercentage = 36; // 36% for 7+ items
|
||||
|
||||
const discountedTotal = total * (1 - discountPercentage / 100);
|
||||
this.setState({ totalPrice: discountedTotal });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
formatPrice(price) {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
calculateSavings() {
|
||||
// Bundle discount calculation
|
||||
const { selectedTentSize, selectedLightType, selectedVentilationType, selectedExtras } = this.state;
|
||||
let itemCount = 0;
|
||||
let originalTotal = 0;
|
||||
|
||||
// Calculate original total without discount
|
||||
if (selectedTentSize) {
|
||||
const tent = tentSizes.find(t => t.id === selectedTentSize);
|
||||
if (tent) {
|
||||
originalTotal += tent.price;
|
||||
itemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLightType) {
|
||||
const light = lightTypes.find(l => l.id === selectedLightType);
|
||||
if (light) {
|
||||
originalTotal += light.price;
|
||||
itemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedVentilationType) {
|
||||
const ventilation = ventilationTypes.find(v => v.id === selectedVentilationType);
|
||||
if (ventilation) {
|
||||
originalTotal += ventilation.price;
|
||||
itemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
selectedExtras.forEach(extraId => {
|
||||
const extra = extras.find(e => e.id === extraId);
|
||||
if (extra) {
|
||||
originalTotal += extra.price;
|
||||
itemCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Progressive discount based on number of selected items
|
||||
let discountPercentage = 0;
|
||||
if (itemCount >= 3) discountPercentage = 15; // 15% for 3+ items
|
||||
if (itemCount >= 5) discountPercentage = 24; // 24% for 5+ items
|
||||
if (itemCount >= 7) discountPercentage = 36; // 36% for 7+ items
|
||||
|
||||
const savings = originalTotal * (discountPercentage / 100);
|
||||
|
||||
return {
|
||||
savings: savings,
|
||||
discountPercentage: discountPercentage,
|
||||
hasDiscount: discountPercentage > 0
|
||||
};
|
||||
}
|
||||
|
||||
renderTentShapeSection() {
|
||||
const { selectedTentShape } = this.state;
|
||||
|
||||
return (
|
||||
<TentShapeSelector
|
||||
tentShapes={tentShapes}
|
||||
selectedShape={selectedTentShape}
|
||||
onShapeSelect={this.handleTentShapeSelect}
|
||||
title="1. Growbox-Form auswählen"
|
||||
subtitle="Wähle zuerst die Grundfläche deiner Growbox aus"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderTentSizeSection() {
|
||||
const { selectedTentSize, selectedTentShape } = this.state;
|
||||
|
||||
// Filter tents by selected shape
|
||||
const filteredTents = tentSizes.filter(tent => tent.shapeId === selectedTentShape);
|
||||
|
||||
if (!selectedTentShape) {
|
||||
return null; // Don't show tent sizes until shape is selected
|
||||
}
|
||||
|
||||
return (
|
||||
<ProductSelector
|
||||
products={filteredTents}
|
||||
selectedValue={selectedTentSize}
|
||||
onSelect={this.handleTentSizeSelect}
|
||||
productType="tent"
|
||||
title="2. Growbox Produkt auswählen"
|
||||
subtitle={`Wähle das passende Produkt für deine ${selectedTentShape} Growbox`}
|
||||
gridSize={{ xs: 12, sm: 6, md: 3 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderLightSection() {
|
||||
const { selectedLightType } = this.state;
|
||||
|
||||
return (
|
||||
<ProductSelector
|
||||
products={lightTypes}
|
||||
selectedValue={selectedLightType}
|
||||
onSelect={this.handleLightTypeSelect}
|
||||
productType="light"
|
||||
title="3. Beleuchtung wählen"
|
||||
gridSize={{ xs: 12, sm: 6 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderVentilationSection() {
|
||||
const { selectedVentilationType } = this.state;
|
||||
|
||||
return (
|
||||
<ProductSelector
|
||||
products={ventilationTypes}
|
||||
selectedValue={selectedVentilationType}
|
||||
onSelect={this.handleVentilationSelect}
|
||||
productType="ventilation"
|
||||
title="4. Belüftung auswählen"
|
||||
gridSize={{ xs: 12, md: 4 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderExtrasSection() {
|
||||
const { selectedExtras } = this.state;
|
||||
|
||||
return (
|
||||
<ExtrasSelector
|
||||
extras={extras}
|
||||
selectedExtras={selectedExtras}
|
||||
onExtraToggle={this.handleExtraToggle}
|
||||
title="5. Extras hinzufügen (optional)"
|
||||
groupByCategory={true}
|
||||
gridSize={{ xs: 12, sm: 6, md: 4 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderInlineSummary() {
|
||||
const { selectedTentSize, selectedLightType, selectedVentilationType, selectedExtras, totalPrice } = this.state;
|
||||
|
||||
const selectedTent = tentSizes.find(t => t.id === selectedTentSize);
|
||||
const selectedLight = lightTypes.find(l => l.id === selectedLightType);
|
||||
const selectedVentilation = ventilationTypes.find(v => v.id === selectedVentilationType);
|
||||
const selectedExtrasItems = extras.filter(e => selectedExtras.includes(e.id));
|
||||
const savingsInfo = this.calculateSavings();
|
||||
|
||||
return (
|
||||
<Paper
|
||||
id="inline-summary" // @note Add ID for scroll targeting
|
||||
elevation={2}
|
||||
sx={{
|
||||
mt: 4,
|
||||
p: 3,
|
||||
bgcolor: '#f8f9fa',
|
||||
border: '2px solid #2e7d32',
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" sx={{ color: '#2e7d32', fontWeight: 'bold', mb: 3, textAlign: 'center' }}>
|
||||
🎯 Ihre Konfiguration
|
||||
</Typography>
|
||||
|
||||
<List sx={{ '& .MuiListItem-root': { py: 1 } }}>
|
||||
{selectedTent && (
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={`Growbox: ${selectedTent.name}`}
|
||||
secondary={selectedTent.description}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{this.formatPrice(selectedTent.price)}
|
||||
</Typography>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
{selectedLight && (
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={`Beleuchtung: ${selectedLight.name}`}
|
||||
secondary={selectedLight.description}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{this.formatPrice(selectedLight.price)}
|
||||
</Typography>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
{selectedVentilation && (
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={`Belüftung: ${selectedVentilation.name}`}
|
||||
secondary={selectedVentilation.description}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{this.formatPrice(selectedVentilation.price)}
|
||||
</Typography>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
{selectedExtrasItems.map(extra => (
|
||||
<ListItem key={extra.id}>
|
||||
<ListItemText
|
||||
primary={`Extra: ${extra.name}`}
|
||||
secondary={extra.description}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{this.formatPrice(extra.price)}
|
||||
</Typography>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{savingsInfo.hasDiscount && (
|
||||
<Box sx={{ mb: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ color: '#d32f2f', fontWeight: 'bold' }}>
|
||||
Sie sparen: {this.formatPrice(savingsInfo.savings)} ({savingsInfo.discountPercentage}% Bundle-Rabatt)
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
|
||||
Gesamtpreis:
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{this.formatPrice(totalPrice)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
sx={{
|
||||
bgcolor: '#2e7d32',
|
||||
'&:hover': { bgcolor: '#1b5e20' },
|
||||
minWidth: 250
|
||||
}}
|
||||
>
|
||||
In den Warenkorb
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Paper elevation={2} sx={{ p: 4, borderRadius: 2 }}>
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Typography variant="h3" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
🌱 Growbox Konfigurator
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Stelle dein perfektes Indoor Grow Setup zusammen
|
||||
</Typography>
|
||||
|
||||
{/* Bundle Discount Information */}
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
mt: 3,
|
||||
p: 2,
|
||||
bgcolor: '#f8f9fa',
|
||||
border: '1px solid #e9ecef',
|
||||
maxWidth: 600,
|
||||
mx: 'auto'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold', mb: 2 }}>
|
||||
🎯 Bundle-Rabatt sichern!
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-around', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ color: '#1976d2', fontWeight: 'bold' }}>
|
||||
15%
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
ab 3 Produkten
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ color: '#ed6c02', fontWeight: 'bold' }}>
|
||||
24%
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
ab 5 Produkten
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ color: '#d32f2f', fontWeight: 'bold' }}>
|
||||
36%
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
ab 7 Produkten
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#666', mt: 1, display: 'block' }}>
|
||||
Je mehr Produkte du auswählst, desto mehr sparst du!
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{this.renderTentShapeSection()}
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
{this.renderTentSizeSection()}
|
||||
{this.state.selectedTentShape && <Divider sx={{ my: 4 }} />}
|
||||
|
||||
{this.renderLightSection()}
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
{this.renderVentilationSection()}
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
{this.renderExtrasSection()}
|
||||
|
||||
{/* Inline summary section - expands when scrolling to bottom */}
|
||||
{this.renderInlineSummary()}
|
||||
|
||||
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default GrowTentKonfigurator;
|
||||
651
src/pages/Home.js
Normal file
651
src/pages/Home.js
Normal file
@@ -0,0 +1,651 @@
|
||||
import React, { useState, useEffect, useContext, useRef } from "react";
|
||||
import Container from "@mui/material/Container";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||
import { Link } from "react-router-dom";
|
||||
import CategoryBox from "../components/CategoryBox.js";
|
||||
import SocketContext from "../contexts/SocketContext.js";
|
||||
import { getCombinedAnimatedBorderStyles } from "../utils/animatedBorderStyles.js";
|
||||
|
||||
// @note SwashingtonCP font is now loaded globally via index.css
|
||||
|
||||
// Carousel styles - Simple styles for JavaScript-based animation
|
||||
const carouselStyles = `
|
||||
.carousel-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
.carousel-wrapper .carousel-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px 0;
|
||||
width: 100%;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.carousel-wrapper .home-carousel-track {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
transition: none;
|
||||
align-items: flex-start;
|
||||
width: 1200px;
|
||||
max-width: 100%;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.carousel-wrapper .carousel-item {
|
||||
flex: 0 0 130px;
|
||||
width: 130px !important;
|
||||
max-width: 130px;
|
||||
min-width: 130px;
|
||||
height: 130px !important;
|
||||
max-height: 130px;
|
||||
min-height: 130px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.carousel-nav-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 20;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.carousel-nav-button:hover {
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.carousel-nav-left {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.carousel-nav-right {
|
||||
right: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
// Generate combined styles for both seeds and cutlings cards
|
||||
const animatedBorderStyle = getCombinedAnimatedBorderStyles([
|
||||
"seeds",
|
||||
"cutlings",
|
||||
]);
|
||||
|
||||
const Home = () => {
|
||||
const carouselRef = useRef(null);
|
||||
const scrollPositionRef = useRef(0);
|
||||
const animationIdRef = useRef(null);
|
||||
const isPausedRef = useRef(false);
|
||||
const resumeTimeoutRef = useRef(null);
|
||||
|
||||
// @note Initialize refs properly
|
||||
useEffect(() => {
|
||||
isPausedRef.current = false;
|
||||
scrollPositionRef.current = 0;
|
||||
}, []);
|
||||
// Helper to process and set categories
|
||||
const processCategoryTree = (categoryTree) => {
|
||||
if (
|
||||
categoryTree &&
|
||||
categoryTree.id === 209 &&
|
||||
Array.isArray(categoryTree.children)
|
||||
) {
|
||||
return categoryTree.children;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Check for cached data - handle both browser and prerender environments
|
||||
const getProductCache = () => {
|
||||
if (typeof window !== "undefined" && window.productCache) {
|
||||
return window.productCache;
|
||||
}
|
||||
if (
|
||||
typeof global !== "undefined" &&
|
||||
global.window &&
|
||||
global.window.productCache
|
||||
) {
|
||||
return global.window.productCache;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Initialize rootCategories from cache if available (for prerendering)
|
||||
const initializeCategories = () => {
|
||||
const productCache = getProductCache();
|
||||
|
||||
if (productCache && productCache["categoryTree_209"]) {
|
||||
const cached = productCache["categoryTree_209"];
|
||||
//const cacheAge = Date.now() - cached.timestamp;
|
||||
//const tenMinutes = 10 * 60 * 1000;
|
||||
if (/*cacheAge < tenMinutes &&*/ cached.categoryTree) {
|
||||
return processCategoryTree(cached.categoryTree);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const [rootCategories, setRootCategories] = useState(() =>
|
||||
initializeCategories()
|
||||
);
|
||||
const socket = useContext(SocketContext);
|
||||
|
||||
useEffect(() => {
|
||||
// Only fetch from socket if we don't already have categories and we're in browser
|
||||
if (
|
||||
rootCategories.length === 0 &&
|
||||
socket &&
|
||||
typeof window !== "undefined"
|
||||
) {
|
||||
socket.emit("categoryList", { categoryId: 209 }, (response) => {
|
||||
if (response && response.categoryTree) {
|
||||
// Store in cache
|
||||
try {
|
||||
if (!window.productCache) window.productCache = {};
|
||||
window.productCache["categoryTree_209"] = {
|
||||
categoryTree: response.categoryTree,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
setRootCategories(response.categoryTree.children || []);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [socket, rootCategories.length]);
|
||||
|
||||
// Filter categories (excluding specific IDs)
|
||||
const filteredCategories = rootCategories.filter(
|
||||
(cat) => cat.id !== 689 && cat.id !== 706
|
||||
);
|
||||
|
||||
// Create duplicated array for seamless scrolling
|
||||
const displayCategories = [...filteredCategories, ...filteredCategories];
|
||||
|
||||
// Auto-scroll effect
|
||||
useEffect(() => {
|
||||
if (filteredCategories.length === 0) return;
|
||||
|
||||
// @note Add a small delay to ensure DOM is ready after prerender
|
||||
const startAnimation = () => {
|
||||
if (!carouselRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// @note Reset paused state when starting animation
|
||||
isPausedRef.current = false;
|
||||
|
||||
const itemWidth = 146; // 130px + 16px gap
|
||||
const totalWidth = filteredCategories.length * itemWidth;
|
||||
|
||||
const animate = () => {
|
||||
// Check if we should be animating
|
||||
if (!animationIdRef.current || isPausedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollPositionRef.current += 0.5; // Speed of scrolling
|
||||
|
||||
// Reset position for seamless loop
|
||||
if (scrollPositionRef.current >= totalWidth) {
|
||||
scrollPositionRef.current = 0;
|
||||
}
|
||||
|
||||
if (carouselRef.current) {
|
||||
const transform = `translateX(-${scrollPositionRef.current}px)`;
|
||||
carouselRef.current.style.transform = transform;
|
||||
}
|
||||
|
||||
animationIdRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
// Only start animation if not paused
|
||||
if (!isPausedRef.current) {
|
||||
animationIdRef.current = requestAnimationFrame(animate);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Try immediately, then with increasing delays to handle prerender scenarios
|
||||
if (!startAnimation()) {
|
||||
const timeout1 = setTimeout(() => {
|
||||
if (!startAnimation()) {
|
||||
const timeout2 = setTimeout(() => {
|
||||
if (!startAnimation()) {
|
||||
const timeout3 = setTimeout(startAnimation, 2000);
|
||||
return () => clearTimeout(timeout3);
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearTimeout(timeout2);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
isPausedRef.current = true;
|
||||
clearTimeout(timeout1);
|
||||
if (animationIdRef.current) {
|
||||
cancelAnimationFrame(animationIdRef.current);
|
||||
}
|
||||
if (resumeTimeoutRef.current) {
|
||||
clearTimeout(resumeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
isPausedRef.current = true;
|
||||
if (animationIdRef.current) {
|
||||
cancelAnimationFrame(animationIdRef.current);
|
||||
}
|
||||
if (resumeTimeoutRef.current) {
|
||||
clearTimeout(resumeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [filteredCategories]);
|
||||
|
||||
// Additional effect to handle cases where categories are available but ref wasn't ready
|
||||
useEffect(() => {
|
||||
if (filteredCategories.length > 0 && carouselRef.current && !animationIdRef.current) {
|
||||
// @note Reset paused state when starting animation
|
||||
isPausedRef.current = false;
|
||||
|
||||
const itemWidth = 146;
|
||||
const totalWidth = filteredCategories.length * itemWidth;
|
||||
|
||||
const animate = () => {
|
||||
if (!animationIdRef.current || isPausedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollPositionRef.current += 0.5;
|
||||
|
||||
if (scrollPositionRef.current >= totalWidth) {
|
||||
scrollPositionRef.current = 0;
|
||||
}
|
||||
|
||||
if (carouselRef.current) {
|
||||
const transform = `translateX(-${scrollPositionRef.current}px)`;
|
||||
carouselRef.current.style.transform = transform;
|
||||
}
|
||||
|
||||
animationIdRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
if (!isPausedRef.current) {
|
||||
animationIdRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Manual navigation
|
||||
const moveCarousel = (direction) => {
|
||||
if (!carouselRef.current) return;
|
||||
|
||||
// Pause auto-scroll
|
||||
isPausedRef.current = true;
|
||||
if (animationIdRef.current) {
|
||||
cancelAnimationFrame(animationIdRef.current);
|
||||
animationIdRef.current = null;
|
||||
}
|
||||
|
||||
const itemWidth = 146;
|
||||
const moveAmount = itemWidth * 3; // Move 3 items at a time
|
||||
const totalWidth = filteredCategories.length * itemWidth;
|
||||
|
||||
if (direction === "left") {
|
||||
scrollPositionRef.current -= moveAmount;
|
||||
// Handle wrapping for infinite scroll
|
||||
if (scrollPositionRef.current < 0) {
|
||||
scrollPositionRef.current = totalWidth + scrollPositionRef.current;
|
||||
}
|
||||
} else {
|
||||
scrollPositionRef.current += moveAmount;
|
||||
// Handle wrapping for infinite scroll
|
||||
if (scrollPositionRef.current >= totalWidth) {
|
||||
scrollPositionRef.current = scrollPositionRef.current % totalWidth;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply smooth transition for manual navigation
|
||||
carouselRef.current.style.transition = "transform 0.5s ease-in-out";
|
||||
carouselRef.current.style.transform = `translateX(-${scrollPositionRef.current}px)`;
|
||||
|
||||
// Remove transition after animation completes
|
||||
setTimeout(() => {
|
||||
if (carouselRef.current) {
|
||||
carouselRef.current.style.transition = "none";
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Clear any existing resume timeout
|
||||
if (resumeTimeoutRef.current) {
|
||||
clearTimeout(resumeTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Resume auto-scroll after 3 seconds
|
||||
resumeTimeoutRef.current = setTimeout(() => {
|
||||
isPausedRef.current = false;
|
||||
|
||||
const animate = () => {
|
||||
if (!animationIdRef.current || isPausedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollPositionRef.current += 0.5;
|
||||
|
||||
if (scrollPositionRef.current >= totalWidth) {
|
||||
scrollPositionRef.current = 0;
|
||||
}
|
||||
|
||||
if (carouselRef.current) {
|
||||
carouselRef.current.style.transform = `translateX(-${scrollPositionRef.current}px)`;
|
||||
}
|
||||
|
||||
animationIdRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationIdRef.current = requestAnimationFrame(animate);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ pt: 4, pb: 2, maxWidth: '1200px !important' }}>
|
||||
{/* Inject the animated border and carousel styles */}
|
||||
<style>{animatedBorderStyle}</style>
|
||||
<style>{carouselStyles}</style>
|
||||
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h1"
|
||||
sx={{
|
||||
mb: 4,
|
||||
fontFamily: "SwashingtonCP",
|
||||
color: "primary.main",
|
||||
textAlign: "center",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
|
||||
}}
|
||||
>
|
||||
ine annabis eeds & uttings
|
||||
</Typography>
|
||||
|
||||
<Grid container sx={{ display: "flex", flexDirection: "row" }}>
|
||||
{/* Seeds Category Box */}
|
||||
<Grid item xs={12} sm={6} sx={{ p: 2, width: "50%" }}>
|
||||
<div className="animated-border-card seeds-card">
|
||||
<Paper
|
||||
component={Link}
|
||||
to="/Kategorie/Seeds"
|
||||
sx={{
|
||||
p: 0,
|
||||
textDecoration: "none",
|
||||
color: "text.primary",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
height: { xs: 250, sm: 300 },
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transition: "all 0.3s ease",
|
||||
boxShadow: 10,
|
||||
"&:hover": {
|
||||
transform: "translateY(-5px)",
|
||||
boxShadow: 20,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Image Container - Place your seeds image here */}
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
bgcolor: "#e1f0d3",
|
||||
backgroundImage: 'url("/assets/images/seeds.jpg")',
|
||||
backgroundSize: "contain",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Overlay text - optional */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bgcolor: "rgba(27, 94, 32, 0.8)",
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "1.6rem",
|
||||
color: "white",
|
||||
fontFamily: "SwashingtonCP",
|
||||
}}
|
||||
>
|
||||
Seeds
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</div>
|
||||
</Grid>
|
||||
|
||||
{/* Cutlings Category Box */}
|
||||
<Grid item xs={12} sm={6} sx={{ p: 2, width: "50%" }}>
|
||||
<div className="animated-border-card cutlings-card">
|
||||
<Paper
|
||||
component={Link}
|
||||
to="/Kategorie/Stecklinge"
|
||||
sx={{
|
||||
p: 0,
|
||||
textDecoration: "none",
|
||||
color: "text.primary",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
height: { xs: 250, sm: 300 },
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: 10,
|
||||
transition: "all 0.3s ease",
|
||||
"&:hover": {
|
||||
transform: "translateY(-5px)",
|
||||
boxShadow: 20,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Image Container - Place your cutlings image here */}
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
bgcolor: "#e8f5d6",
|
||||
backgroundImage: 'url("/assets/images/cutlings.jpg")',
|
||||
backgroundSize: "contain",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Overlay text - optional */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bgcolor: "rgba(27, 94, 32, 0.8)",
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "1.6rem",
|
||||
color: "white",
|
||||
fontFamily: "SwashingtonCP",
|
||||
}}
|
||||
>
|
||||
Stecklinge
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Continuous Rotating Carousel for Categories */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
sx={{
|
||||
mb: 2,
|
||||
fontFamily: "SwashingtonCP",
|
||||
color: "primary.main",
|
||||
textAlign: "center",
|
||||
textShadow: "3px 3px 10px rgba(0, 0, 0, 0.4)"
|
||||
}}
|
||||
>
|
||||
Kategorien
|
||||
</Typography>
|
||||
|
||||
{filteredCategories.length > 0 && (
|
||||
<div
|
||||
className="carousel-wrapper"
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
padding: '0 20px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
{/* Left Arrow */}
|
||||
<IconButton
|
||||
onClick={() => moveCarousel("left")}
|
||||
aria-label="Vorherige Kategorien anzeigen"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '8px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1200,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
|
||||
{/* Right Arrow */}
|
||||
<IconButton
|
||||
onClick={() => moveCarousel("right")}
|
||||
aria-label="Nächste Kategorien anzeigen"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: '8px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1200,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
|
||||
<div
|
||||
className="carousel-container"
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
padding: '20px 0',
|
||||
width: '100%',
|
||||
maxWidth: '1080px',
|
||||
margin: '0 auto',
|
||||
zIndex: 1,
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="home-carousel-track"
|
||||
ref={carouselRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
transition: 'none',
|
||||
alignItems: 'flex-start',
|
||||
width: 'fit-content',
|
||||
overflow: 'visible',
|
||||
position: 'relative',
|
||||
transform: 'translateX(0px)',
|
||||
margin: '0 auto'
|
||||
}}
|
||||
>
|
||||
{displayCategories.map((category, index) => (
|
||||
<div
|
||||
key={`${category.id}-${index}`}
|
||||
className="carousel-item"
|
||||
style={{
|
||||
flex: '0 0 130px',
|
||||
width: '130px',
|
||||
maxWidth: '130px',
|
||||
minWidth: '130px',
|
||||
height: '130px',
|
||||
maxHeight: '130px',
|
||||
minHeight: '130px',
|
||||
boxSizing: 'border-box',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<CategoryBox
|
||||
id={category.id}
|
||||
name={category.name}
|
||||
seoName={category.seoName}
|
||||
image={category.image}
|
||||
bgcolor={category.bgcolor}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
52
src/pages/Impressum.js
Normal file
52
src/pages/Impressum.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
import LegalPage from './LegalPage.js';
|
||||
|
||||
const Impressum = () => {
|
||||
const content = (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Betreiber und verantwortlich für die Inhalte dieses Shops ist:
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Growheads<br />
|
||||
Max Schön<br />
|
||||
Trachenberger Straße 14<br />
|
||||
01129 Dresden
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Kontakt:
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
E-Mail: service@growheads.de
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Umsatzsteuer-ID:
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
USt.-IdNr.: DE323017152
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Haftungsausschluss:
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Für Inhalte von auf diesen Seiten verlinkten externen Internetadressen übernehmen wir keine Haftung. Für Inhalte betriebsfremder Domizile sind die jeweiligen Betreiber verantwortlich.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Urheberrechtsklausel:
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Die hier dargestellten Inhalte unterliegen grundsätzlich dem Urheberrecht und dürfen nur mit schriftlicher Genehmigung verbreitet werden.
|
||||
Die Rechte an Foto- oder Textmaterial von anderen Parteien sind durch diese Klausel weder eingeschränkt noch aufgehoben.
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
|
||||
return <LegalPage title="Impressum" content={content} />;
|
||||
};
|
||||
|
||||
export default Impressum;
|
||||
22
src/pages/LegalPage.js
Normal file
22
src/pages/LegalPage.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import Container from '@mui/material/Container';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
|
||||
const LegalPage = ({ title, content }) => {
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 6 }}>
|
||||
<Paper sx={{ p: 4, borderRadius: 2 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
{title}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{content}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default LegalPage;
|
||||
232
src/pages/ProfilePage.js
Normal file
232
src/pages/ProfilePage.js
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
Tabs,
|
||||
Tab,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import { useLocation, useNavigate, Navigate } from 'react-router-dom';
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
|
||||
// Import extracted components
|
||||
import OrdersTab from '../components/profile/OrdersTab.js';
|
||||
import SettingsTab from '../components/profile/SettingsTab.js';
|
||||
import CartTab from '../components/profile/CartTab.js';
|
||||
import LoginComponent from '../components/LoginComponent.js';
|
||||
|
||||
// Functional Profile Page Component
|
||||
const ProfilePage = (props) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [orderIdFromHash, setOrderIdFromHash] = useState(null);
|
||||
const [paymentCompletion, setPaymentCompletion] = useState(null);
|
||||
|
||||
// @note Check for payment completion parameters from Stripe redirect
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const isComplete = urlParams.has('complete');
|
||||
const paymentIntent = urlParams.get('payment_intent');
|
||||
const paymentIntentClientSecret = urlParams.get('payment_intent_client_secret');
|
||||
const redirectStatus = urlParams.get('redirect_status');
|
||||
|
||||
if (isComplete && paymentIntent && redirectStatus) {
|
||||
setPaymentCompletion({
|
||||
paymentIntent,
|
||||
paymentIntentClientSecret,
|
||||
redirectStatus,
|
||||
isSuccessful: redirectStatus === 'succeeded'
|
||||
});
|
||||
|
||||
// Clean up the URL by removing the payment parameters
|
||||
const newUrl = new URL(window.location);
|
||||
newUrl.searchParams.delete('complete');
|
||||
newUrl.searchParams.delete('payment_intent');
|
||||
newUrl.searchParams.delete('payment_intent_client_secret');
|
||||
newUrl.searchParams.delete('redirect_status');
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
}
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
|
||||
switch (hash) {
|
||||
case '#cart':
|
||||
setTabValue(0);
|
||||
setOrderIdFromHash(null);
|
||||
break;
|
||||
case '#orders':
|
||||
setTabValue(1);
|
||||
setOrderIdFromHash(null);
|
||||
break;
|
||||
case '#settings':
|
||||
setTabValue(2);
|
||||
setOrderIdFromHash(null);
|
||||
break;
|
||||
default:
|
||||
if (hash && hash.startsWith('#ORD-')) {
|
||||
const orderId = hash.substring(1);
|
||||
setOrderIdFromHash(orderId);
|
||||
setTabValue(1); // Switch to Orders tab
|
||||
} else {
|
||||
setOrderIdFromHash(null);
|
||||
}
|
||||
}
|
||||
}, [location.hash]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkUserLoggedIn = () => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (!storedUser) {
|
||||
setShowLogin(true);
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userData = JSON.parse(storedUser);
|
||||
if (!userData) {
|
||||
setShowLogin(true);
|
||||
setUser(null);
|
||||
} else if (!user) {
|
||||
setUser(userData);
|
||||
setShowLogin(false); // Hide login on successful user load
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing user from sessionStorage:', error);
|
||||
setShowLogin(true);
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkUserLoggedIn();
|
||||
const checkLoginInterval = setInterval(checkUserLoggedIn, 1000);
|
||||
|
||||
const handleStorageChange = (e) => {
|
||||
if (e.key === 'user' && !e.newValue) {
|
||||
// User was removed from sessionStorage in another tab
|
||||
setShowLogin(true);
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
clearInterval(checkLoginInterval);
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, [user, loading]);
|
||||
|
||||
const handleTabChange = (event, newValue) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
const handleGoToOrders = () => {
|
||||
setTabValue(1);
|
||||
};
|
||||
|
||||
const handleClearPaymentCompletion = () => {
|
||||
setPaymentCompletion(null);
|
||||
};
|
||||
|
||||
const handleLoginClose = () => {
|
||||
setShowLogin(false);
|
||||
// If user closes login without logging in, redirect to home.
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (!storedUser) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (showLogin) {
|
||||
return <LoginComponent open={showLogin} handleClose={handleLoginClose} location={location} socket={props.socket} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 5 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Paper elevation={2} sx={{ borderRadius: 2, overflow: 'hidden' }}>
|
||||
<Box sx={{ bgcolor: '#2e7d32', p: 3, color: 'white' }}>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
Mein Profil
|
||||
</Typography>
|
||||
{user && (
|
||||
<Typography variant="body1" sx={{ mt: 1 }}>
|
||||
{user.email}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={handleTabChange}
|
||||
variant="fullWidth"
|
||||
sx={{ borderBottom: 1, borderColor: 'divider' }}
|
||||
TabIndicatorProps={{
|
||||
style: { backgroundColor: '#2e7d32' }
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
label="Bestellabschluss"
|
||||
sx={{
|
||||
color: tabValue === 0 ? '#2e7d32' : 'inherit',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Bestellungen"
|
||||
sx={{
|
||||
color: tabValue === 1 ? '#2e7d32' : 'inherit',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Einstellungen"
|
||||
sx={{
|
||||
color: tabValue === 2 ? '#2e7d32' : 'inherit',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{tabValue === 0 && <CartTab onOrderSuccess={handleGoToOrders} paymentCompletion={paymentCompletion} onClearPaymentCompletion={handleClearPaymentCompletion} />}
|
||||
{tabValue === 1 && <OrdersTab orderIdFromHash={orderIdFromHash} />}
|
||||
{tabValue === 2 && <SettingsTab socket={props.socket} />}
|
||||
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrap with socket context
|
||||
const ProfilePageWithSocket = (props) => {
|
||||
const socket = useContext(SocketContext);
|
||||
return <ProfilePage {...props} socket={socket} />;
|
||||
};
|
||||
|
||||
export default ProfilePageWithSocket;
|
||||
176
src/pages/ResetPassword.js
Normal file
176
src/pages/ResetPassword.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Alert,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import LockResetIcon from '@mui/icons-material/LockReset';
|
||||
|
||||
const ResetPassword = ({ socket }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [token, setToken] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Extract token from URL query parameters
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const tokenFromUrl = urlParams.get('token');
|
||||
|
||||
if (!tokenFromUrl) {
|
||||
setError('Kein gültiger Token gefunden. Bitte verwenden Sie den Link aus Ihrer E-Mail.');
|
||||
} else {
|
||||
setToken(tokenFromUrl);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!newPassword || !confirmPassword) {
|
||||
setError('Bitte füllen Sie alle Felder aus');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('Das Passwort muss mindestens 8 Zeichen lang sein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Die Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Emit verifyResetToken event
|
||||
socket.emit('verifyResetToken', {
|
||||
token: token,
|
||||
newPassword: newPassword
|
||||
}, (response) => {
|
||||
setLoading(false);
|
||||
|
||||
if (response.success) {
|
||||
setSuccess(true);
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
// Open login drawer if available
|
||||
if (window.openLoginDrawer) {
|
||||
window.openLoginDrawer();
|
||||
}
|
||||
}, 3000);
|
||||
} else {
|
||||
setError(response.message || 'Fehler beim Zurücksetzen des Passworts');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ mt: 8, mb: 4 }}>
|
||||
<Paper elevation={3} sx={{ p: 4 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<LockResetIcon sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
|
||||
<Typography component="h1" variant="h5" gutterBottom>
|
||||
Passwort zurücksetzen
|
||||
</Typography>
|
||||
|
||||
{!token ? (
|
||||
<Alert severity="error" sx={{ mt: 2, width: '100%' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : success ? (
|
||||
<Box sx={{ width: '100%', mt: 2 }}>
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
Ihr Passwort wurde erfolgreich zurückgesetzt! Sie werden in Kürze zur Anmeldung weitergeleitet...
|
||||
</Alert>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2, width: '100%' }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="newPassword"
|
||||
label="Neues Passwort"
|
||||
type="password"
|
||||
id="newPassword"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="confirmPassword"
|
||||
label="Passwort bestätigen"
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
bgcolor: '#2e7d32',
|
||||
'&:hover': { bgcolor: '#1b5e20' }
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
'Passwort zurücksetzen'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => navigate('/')}
|
||||
sx={{ color: '#2e7d32' }}
|
||||
>
|
||||
Zurück zur Startseite
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
400
src/pages/ServerLogsPage.js
Normal file
400
src/pages/ServerLogsPage.js
Normal file
@@ -0,0 +1,400 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Paper,
|
||||
Card,
|
||||
CardContent,
|
||||
List,
|
||||
ListItem,
|
||||
Tabs,
|
||||
Tab,
|
||||
Stack,
|
||||
Button,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import { Navigate, Link } from 'react-router-dom';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import { ADMIN_COLORS, getAdminStyles } from '../theme/adminColors.js';
|
||||
|
||||
class ServerLogsPage extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
user: null,
|
||||
loading: true,
|
||||
redirect: false,
|
||||
logs: [],
|
||||
historicalLogsLoaded: false
|
||||
};
|
||||
}
|
||||
|
||||
checkUserLoggedIn = () => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (!storedUser) {
|
||||
this.setState({ redirect: true, user: null });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userData = JSON.parse(storedUser);
|
||||
if (!userData) {
|
||||
this.setState({ redirect: true, user: null });
|
||||
} else if (!this.state.user) {
|
||||
// Only update user if it's not already set
|
||||
this.setState({ user: userData, loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing user from sessionStorage:', error);
|
||||
this.setState({ redirect: true, user: null });
|
||||
}
|
||||
|
||||
// Once loading is complete
|
||||
if (this.state.loading) {
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleStorageChange = (e) => {
|
||||
if (e.key === 'user' && !e.newValue) {
|
||||
// User was removed from sessionStorage in another tab
|
||||
this.setState({ redirect: true, user: null });
|
||||
}
|
||||
}
|
||||
|
||||
handleLogEntry = (logEntry) => {
|
||||
console.log(`[${logEntry.timestamp}] ${logEntry.level.toUpperCase()}: ${logEntry.message}`);
|
||||
this.setState(prevState => ({
|
||||
logs: [logEntry, ...prevState.logs].slice(0, 250) // Keep only last 250 logs
|
||||
}));
|
||||
}
|
||||
|
||||
parseHistoricalLogLine = (logLine) => {
|
||||
// Try to parse JSON formatted log entries first
|
||||
try {
|
||||
const parsed = JSON.parse(logLine);
|
||||
if (parsed.level && parsed.message && parsed.timestamp) {
|
||||
return {
|
||||
timestamp: parsed.timestamp,
|
||||
level: parsed.level.toLowerCase(),
|
||||
message: parsed.message
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, try other formats
|
||||
}
|
||||
|
||||
// Try to parse bracket format like: "[2024-01-01T12:00:00.000Z] INFO: message content"
|
||||
const match = logLine.match(/^\[(.+?)\]\s+(\w+):\s*(.*)$/);
|
||||
if (match) {
|
||||
return {
|
||||
timestamp: match[1],
|
||||
level: match[2].toLowerCase(),
|
||||
message: match[3]
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for lines that don't match expected format
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: logLine
|
||||
};
|
||||
}
|
||||
|
||||
loadHistoricalLogs = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('getLog', (response) => {
|
||||
if (response.success) {
|
||||
console.log('Last 50 historical logs:', response.data.lines);
|
||||
const historicalLogs = (response.data.lines || [])
|
||||
.map(line => this.parseHistoricalLogLine(line))
|
||||
.reverse(); // Reverse to have newest first
|
||||
|
||||
this.setState(prevState => ({
|
||||
logs: [...historicalLogs, ...prevState.logs].slice(0, 250),
|
||||
historicalLogsLoaded: true
|
||||
}));
|
||||
} else {
|
||||
console.warn('Failed to load historical logs:', response);
|
||||
this.setState({ historicalLogsLoaded: true }); // Mark as attempted even if failed
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.addSocketListeners();
|
||||
this.checkUserLoggedIn();
|
||||
this.loadHistoricalLogs(); // Load historical logs on mount
|
||||
// Set up interval to regularly check login status
|
||||
this.checkLoginInterval = setInterval(this.checkUserLoggedIn, 1000);
|
||||
// Add storage event listener to detect when user logs out in other tabs
|
||||
window.addEventListener('storage', this.handleStorageChange);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected) {
|
||||
// Socket just connected, add listeners and reload data
|
||||
this.addSocketListeners();
|
||||
if (!this.state.historicalLogsLoaded) {
|
||||
this.loadHistoricalLogs();
|
||||
}
|
||||
} else if (wasConnected && !isNowConnected) {
|
||||
// Socket just disconnected, remove listeners
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.removeSocketListeners();
|
||||
// Clear interval and remove event listeners
|
||||
if (this.checkLoginInterval) {
|
||||
clearInterval(this.checkLoginInterval);
|
||||
}
|
||||
window.removeEventListener('storage', this.handleStorageChange);
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('log', this.handleLogEntry);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('log', this.handleLogEntry);
|
||||
}
|
||||
}
|
||||
|
||||
formatLogLevel = (level) => {
|
||||
const colors = {
|
||||
error: ADMIN_COLORS.error,
|
||||
warn: ADMIN_COLORS.warning,
|
||||
info: ADMIN_COLORS.primary,
|
||||
debug: ADMIN_COLORS.secondary,
|
||||
verbose: ADMIN_COLORS.secondaryText
|
||||
};
|
||||
return colors[level.toLowerCase()] || ADMIN_COLORS.primaryText;
|
||||
}
|
||||
|
||||
clearLogs = () => {
|
||||
this.setState({ logs: [] });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.redirect || (!this.state.loading && !this.state.user)) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
// Check if current user is admin
|
||||
if (this.state.user && !this.state.user.admin) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
const styles = getAdminStyles();
|
||||
|
||||
return (
|
||||
<Box sx={styles.pageContainer}>
|
||||
<Container
|
||||
maxWidth="lg"
|
||||
sx={{
|
||||
py: 6
|
||||
}}
|
||||
>
|
||||
{/* Admin Navigation Tabs */}
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
mb: 3,
|
||||
...styles.tabBar
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={2}
|
||||
indicatorColor="primary"
|
||||
sx={{
|
||||
px: 2,
|
||||
'& .MuiTabs-indicator': {
|
||||
backgroundColor: ADMIN_COLORS.primary
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
label="Dashboard"
|
||||
component={Link}
|
||||
to="/admin"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
'&:hover': {
|
||||
color: ADMIN_COLORS.primaryBright
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Users"
|
||||
component={Link}
|
||||
to="/admin/users"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
'&:hover': {
|
||||
color: ADMIN_COLORS.primaryBright
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Server Logs"
|
||||
component={Link}
|
||||
to="/admin/logs"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: ADMIN_COLORS.primary,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Server Logs Content */}
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
...styles.contentPaper
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
...styles.primaryHeading
|
||||
}}
|
||||
>
|
||||
<ArticleIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
|
||||
Server Logs
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={this.clearLogs}
|
||||
size="small"
|
||||
sx={{
|
||||
color: ADMIN_COLORS.warning,
|
||||
borderColor: ADMIN_COLORS.warning,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
textTransform: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: ADMIN_COLORS.warning,
|
||||
color: ADMIN_COLORS.hoverBackground,
|
||||
borderColor: ADMIN_COLORS.warning
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear Logs
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{this.state.logs.length === 0 ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
...styles.secondaryText,
|
||||
p: 2
|
||||
}}
|
||||
>
|
||||
{!this.state.historicalLogsLoaded
|
||||
? "Loading historical logs..."
|
||||
: "No logs available. New logs will appear here in real-time."}
|
||||
</Typography>
|
||||
) : (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
...styles.card,
|
||||
borderRadius: 1
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
<List dense sx={{ p: 0 }}>
|
||||
{this.state.logs.map((log, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
sx={{
|
||||
py: 0.25,
|
||||
px: 1,
|
||||
borderBottom: index < this.state.logs.length - 1 ? `1px solid ${ADMIN_COLORS.hoverBackground}` : 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: ADMIN_COLORS.hoverBackground
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="flex-start" sx={{ width: '100%', fontFamily: ADMIN_COLORS.fontFamily }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: ADMIN_COLORS.secondaryText,
|
||||
minWidth: 160,
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{log.timestamp}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: this.formatLogLevel(log.level),
|
||||
minWidth: 60,
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0,
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
>
|
||||
{log.level}:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
wordBreak: 'break-word',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontSize: '0.875rem',
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
lineHeight: 1.4
|
||||
}}
|
||||
>
|
||||
{log.message}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerLogsPage;
|
||||
171
src/pages/Sitemap.js
Normal file
171
src/pages/Sitemap.js
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import LegalPage from './LegalPage.js';
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
|
||||
// Helper function to recursively collect all categories from the tree
|
||||
const collectAllCategories = (categoryNode, categories = [], level = 0) => {
|
||||
if (!categoryNode) return categories;
|
||||
|
||||
// Add current category (skip root category 209)
|
||||
if (categoryNode.id !== 209 && categoryNode.seoName) {
|
||||
categories.push({
|
||||
id: categoryNode.id,
|
||||
name: categoryNode.name,
|
||||
seoName: categoryNode.seoName,
|
||||
level: level
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively add children
|
||||
if (categoryNode.children) {
|
||||
for (const child of categoryNode.children) {
|
||||
collectAllCategories(child, categories, level + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
const Sitemap = () => {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const socket = useContext(SocketContext);
|
||||
|
||||
const sitemapLinks = [
|
||||
{ title: 'Startseite', url: '/' },
|
||||
{ title: 'Mein Profil', url: '/profile' },
|
||||
{ title: 'Datenschutz', url: '/datenschutz' },
|
||||
{ title: 'AGB', url: '/agb' },
|
||||
{ title: 'Impressum', url: '/impressum' },
|
||||
{ title: 'Batteriegesetzhinweise', url: '/batteriegesetzhinweise' },
|
||||
{ title: 'Widerrufsrecht', url: '/widerrufsrecht' },
|
||||
{ title: 'Growbox Konfigurator', url: '/Konfigurator' },
|
||||
{ title: 'API', url: '/api/', route:false },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCategories = () => {
|
||||
// Try cache first
|
||||
if (window.productCache && window.productCache['categoryTree_209']) {
|
||||
const cached = window.productCache['categoryTree_209'];
|
||||
const cacheAge = Date.now() - cached.timestamp;
|
||||
const tenMinutes = 10 * 60 * 1000;
|
||||
if (cacheAge < tenMinutes && cached.categoryTree) {
|
||||
const allCategories = collectAllCategories(cached.categoryTree);
|
||||
setCategories(allCategories);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, fetch from socket if available
|
||||
if (socket) {
|
||||
socket.emit('categoryList', { categoryId: 209 }, (response) => {
|
||||
if (response && response.categoryTree) {
|
||||
// Store in cache
|
||||
try {
|
||||
if (!window.productCache) window.productCache = {};
|
||||
window.productCache['categoryTree_209'] = {
|
||||
categoryTree: response.categoryTree,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error caching category data:', err);
|
||||
}
|
||||
|
||||
const allCategories = collectAllCategories(response.categoryTree);
|
||||
setCategories(allCategories);
|
||||
setLoading(false);
|
||||
} else {
|
||||
console.error('Failed to fetch categories');
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCategories();
|
||||
}, [socket]);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<Typography variant="body1" paragraph>
|
||||
Hier finden Sie eine Übersicht aller verfügbaren Seiten unserer Website.
|
||||
</Typography>
|
||||
|
||||
{/* @note Static site links */}
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 2, fontWeight: 'bold' }}>
|
||||
Seiten
|
||||
</Typography>
|
||||
<List>
|
||||
{sitemapLinks.map((link) => (
|
||||
<ListItem
|
||||
button
|
||||
component={link.route === false ? 'a' : RouterLink}
|
||||
{...(link.route === false ? { href: link.url } : { to: link.url })}
|
||||
key={link.url}
|
||||
sx={{
|
||||
py: 1,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<ListItemText primary={link.title} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{/* @note Category links */}
|
||||
<Typography variant="h6" sx={{ mt: 4, mb: 2, fontWeight: 'bold' }}>
|
||||
Kategorien
|
||||
</Typography>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<List>
|
||||
{categories.map((category) => (
|
||||
<ListItem
|
||||
button
|
||||
component={RouterLink}
|
||||
to={`/Kategorie/${category.seoName}`}
|
||||
key={category.id}
|
||||
sx={{
|
||||
py: 1,
|
||||
pl: 2 + (category.level * 2), // Indent based on category level
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={category.name}
|
||||
sx={{
|
||||
'& .MuiTypography-root': {
|
||||
fontSize: category.level === 0 ? '1rem' : '0.9rem',
|
||||
fontWeight: category.level === 0 ? 'bold' : 'normal',
|
||||
color: category.level === 0 ? 'primary.main' : 'text.primary'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return <LegalPage title="Sitemap" content={content} />;
|
||||
};
|
||||
|
||||
export default Sitemap;
|
||||
719
src/pages/UsersPage.js
Normal file
719
src/pages/UsersPage.js
Normal file
@@ -0,0 +1,719 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Paper,
|
||||
Box,
|
||||
Divider,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Avatar,
|
||||
Tabs,
|
||||
Tab,
|
||||
Stack,
|
||||
Button,
|
||||
Snackbar,
|
||||
Alert,
|
||||
Link as MuiLink
|
||||
} from '@mui/material';
|
||||
import { Navigate, Link } from 'react-router-dom';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||
import GroupIcon from '@mui/icons-material/Group';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import { ADMIN_COLORS, getAdminStyles } from '../theme/adminColors.js';
|
||||
|
||||
class UsersPage extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
user: null,
|
||||
users: [],
|
||||
totalCount: 0,
|
||||
totalOrders: 0,
|
||||
loading: true,
|
||||
redirect: false,
|
||||
switchingUser: false,
|
||||
notification: {
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'success'
|
||||
},
|
||||
currentlyImpersonating: null
|
||||
};
|
||||
}
|
||||
|
||||
checkUserLoggedIn = () => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (!storedUser) {
|
||||
this.setState({ redirect: true, user: null });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userData = JSON.parse(storedUser);
|
||||
if (!userData) {
|
||||
this.setState({ redirect: true, user: null });
|
||||
} else if (!this.state.user) {
|
||||
// Only update user if it's not already set
|
||||
this.setState({ user: userData, loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing user from sessionStorage:', error);
|
||||
this.setState({ redirect: true, user: null });
|
||||
}
|
||||
|
||||
// Once loading is complete
|
||||
if (this.state.loading) {
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleStorageChange = (e) => {
|
||||
if (e.key === 'user' && !e.newValue) {
|
||||
// User was removed from sessionStorage in another tab
|
||||
this.setState({ redirect: true, user: null });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadInitialData();
|
||||
this.checkUserLoggedIn();
|
||||
// Set up interval to regularly check login status
|
||||
this.checkLoginInterval = setInterval(this.checkUserLoggedIn, 1000);
|
||||
// Add storage event listener to detect when user logs out in other tabs
|
||||
window.addEventListener('storage', this.handleStorageChange);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected) {
|
||||
// Socket just connected, reload data
|
||||
this.loadInitialData();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Clear interval and remove event listeners
|
||||
if (this.checkLoginInterval) {
|
||||
clearInterval(this.checkLoginInterval);
|
||||
}
|
||||
window.removeEventListener('storage', this.handleStorageChange);
|
||||
}
|
||||
|
||||
loadInitialData = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('getUsers', (response) => {
|
||||
if (response.success) {
|
||||
console.log('Users:', response.data.users);
|
||||
console.log('Total count:', response.data.totalCount);
|
||||
console.log('Total orders:', response.data.totalOrders);
|
||||
this.setState({
|
||||
users: response.data.users,
|
||||
totalCount: response.data.totalCount,
|
||||
totalOrders: response.data.totalOrders
|
||||
});
|
||||
} else {
|
||||
console.error('Error:', response.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formatDate = (dateString) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return dateString; // Return original string if date is invalid
|
||||
}
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error formatting date:', error);
|
||||
return dateString; // Return original string if formatting fails
|
||||
}
|
||||
}
|
||||
|
||||
getOrderStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
case 'delivered':
|
||||
return 'success';
|
||||
case 'pending':
|
||||
return 'warning';
|
||||
case 'processing':
|
||||
case 'shipped':
|
||||
return 'info';
|
||||
case 'cancelled':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
getOrderStatusChipColor = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
case 'delivered':
|
||||
return ADMIN_COLORS.primary;
|
||||
case 'pending':
|
||||
return ADMIN_COLORS.warning;
|
||||
case 'processing':
|
||||
case 'shipped':
|
||||
return ADMIN_COLORS.secondary;
|
||||
case 'cancelled':
|
||||
return ADMIN_COLORS.error;
|
||||
default:
|
||||
return ADMIN_COLORS.secondaryText;
|
||||
}
|
||||
}
|
||||
|
||||
formatPrice = (price) => {
|
||||
return typeof price === 'number'
|
||||
? `€${price.toFixed(2)}`
|
||||
: price;
|
||||
}
|
||||
|
||||
handleSwitchUser = (email) => {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
this.showNotification('Socket not connected', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ switchingUser: true });
|
||||
|
||||
this.props.socket.emit('switchUser', { email }, (response) => {
|
||||
console.log('Switch user response:', response);
|
||||
this.setState({ switchingUser: false });
|
||||
|
||||
if (response.success) {
|
||||
this.setState({ currentlyImpersonating: response.data.targetUser });
|
||||
this.showNotification(`Successfully switched to user: ${email}`, 'success');
|
||||
|
||||
// Update sessionStorage with the switched user info
|
||||
const currentUser = JSON.parse(sessionStorage.getItem('user') || '{}');
|
||||
const switchedUser = {
|
||||
...currentUser,
|
||||
id: response.data.targetUser.id,
|
||||
email: response.data.targetUser.email,
|
||||
admin: true, // Admin privileges are preserved
|
||||
originalAdmin: response.data.originalAdmin
|
||||
};
|
||||
sessionStorage.setItem('user', JSON.stringify(switchedUser));
|
||||
|
||||
// Trigger userLoggedIn event to refresh other components
|
||||
window.dispatchEvent(new Event('userLoggedIn'));
|
||||
} else {
|
||||
this.showNotification(`Failed to switch user: ${response.error}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleSwitchBackToAdmin = () => {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
this.showNotification('Socket not connected', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ switchingUser: true });
|
||||
|
||||
this.props.socket.emit('switchBackToAdmin', (response) => {
|
||||
console.log('Switch back to admin response:', response);
|
||||
this.setState({ switchingUser: false });
|
||||
|
||||
if (response.success) {
|
||||
this.setState({ currentlyImpersonating: null });
|
||||
this.showNotification(`Switched back to admin`, 'success');
|
||||
|
||||
// Restore original admin info in sessionStorage
|
||||
const currentUser = JSON.parse(sessionStorage.getItem('user') || '{}');
|
||||
if (currentUser.originalAdmin) {
|
||||
const restoredAdmin = {
|
||||
...currentUser,
|
||||
id: currentUser.originalAdmin.id,
|
||||
email: currentUser.originalAdmin.email,
|
||||
admin: true
|
||||
};
|
||||
delete restoredAdmin.originalAdmin;
|
||||
sessionStorage.setItem('user', JSON.stringify(restoredAdmin));
|
||||
}
|
||||
|
||||
// Trigger userLoggedIn event to refresh other components
|
||||
window.dispatchEvent(new Event('userLoggedIn'));
|
||||
} else {
|
||||
this.showNotification(`Failed to switch back: ${response.error}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showNotification = (message, severity = 'success') => {
|
||||
console.log('Showing notification:', message, severity);
|
||||
this.setState({
|
||||
notification: {
|
||||
open: true,
|
||||
message,
|
||||
severity
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleCloseNotification = () => {
|
||||
this.setState({
|
||||
notification: {
|
||||
...this.state.notification,
|
||||
open: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { users, totalCount, totalOrders } = this.state;
|
||||
|
||||
if (this.state.redirect || (!this.state.loading && !this.state.user)) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
// Check if current user is admin
|
||||
if (this.state.user && !this.state.user.admin) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
const hasUsers = users && users.length > 0;
|
||||
const styles = getAdminStyles();
|
||||
|
||||
return (
|
||||
<Box sx={styles.pageContainer}>
|
||||
<Container
|
||||
maxWidth="lg"
|
||||
sx={{
|
||||
py: 6
|
||||
}}
|
||||
>
|
||||
{/* Admin Navigation Tabs */}
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
mb: 3,
|
||||
...styles.tabBar
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={1}
|
||||
sx={{
|
||||
px: 2,
|
||||
'& .MuiTabs-indicator': {
|
||||
backgroundColor: ADMIN_COLORS.primary
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
label="Dashboard"
|
||||
component={Link}
|
||||
to="/admin"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
'&:hover': {
|
||||
color: ADMIN_COLORS.primaryBright
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Users"
|
||||
component={Link}
|
||||
to="/admin/users"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: ADMIN_COLORS.primary,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Server Logs"
|
||||
component={Link}
|
||||
to="/admin/logs"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
'&:hover': {
|
||||
color: ADMIN_COLORS.primaryBright
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
...styles.contentPaper
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
...styles.primaryHeading
|
||||
}}
|
||||
>
|
||||
<GroupIcon sx={{ mr: 1, color: ADMIN_COLORS.primary }} />
|
||||
User Management
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{this.state.currentlyImpersonating && (
|
||||
<>
|
||||
<Chip
|
||||
label={`Impersonating: ${this.state.currentlyImpersonating.email}`}
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 'medium',
|
||||
backgroundColor: ADMIN_COLORS.magenta,
|
||||
color: ADMIN_COLORS.hoverBackground,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={this.handleSwitchBackToAdmin}
|
||||
disabled={this.state.switchingUser}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
borderColor: ADMIN_COLORS.border,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
'&:hover': {
|
||||
borderColor: ADMIN_COLORS.primary,
|
||||
backgroundColor: 'rgba(80, 250, 123, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Switch Back to Admin
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={4} sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<GroupIcon sx={{ mr: 1, color: ADMIN_COLORS.secondary }} />
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
...styles.primaryText
|
||||
}}
|
||||
>
|
||||
Total Users: <strong style={{ color: ADMIN_COLORS.warning }}>{totalCount}</strong>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ShoppingCartIcon sx={{ mr: 1, color: ADMIN_COLORS.secondary }} />
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
...styles.primaryText
|
||||
}}
|
||||
>
|
||||
Total Orders: <strong style={{ color: ADMIN_COLORS.warning }}>{totalOrders}</strong>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{!hasUsers && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
mt: 2,
|
||||
...styles.secondaryText
|
||||
}}
|
||||
>
|
||||
No users found.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{hasUsers && (
|
||||
<Grid container spacing={3} sx={{ mt: 1 }}>
|
||||
{users.map((user, i) => (
|
||||
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={user.id || i}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
height: '100%',
|
||||
...styles.card
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, width: '100%' }}>
|
||||
<Avatar sx={{
|
||||
mr: 2,
|
||||
bgcolor: user.admin ? ADMIN_COLORS.magenta : ADMIN_COLORS.secondary,
|
||||
color: ADMIN_COLORS.hoverBackground
|
||||
}}>
|
||||
{user.admin ? <AdminPanelSettingsIcon /> : <PersonIcon />}
|
||||
</Avatar>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="div"
|
||||
noWrap
|
||||
sx={{
|
||||
...styles.primaryHeading
|
||||
}}
|
||||
>
|
||||
User #{user.id}
|
||||
</Typography>
|
||||
<MuiLink
|
||||
component="button"
|
||||
onClick={() => this.handleSwitchUser(user.email)}
|
||||
disabled={this.state.switchingUser}
|
||||
sx={{
|
||||
color: ADMIN_COLORS.secondary,
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'medium',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
padding: 0,
|
||||
textAlign: 'left',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
'&:hover': {
|
||||
textDecorationColor: ADMIN_COLORS.secondary,
|
||||
color: ADMIN_COLORS.primaryBright
|
||||
},
|
||||
'&:disabled': {
|
||||
color: ADMIN_COLORS.secondaryText,
|
||||
cursor: 'not-allowed'
|
||||
}
|
||||
}}
|
||||
title="Click to switch to this user"
|
||||
>
|
||||
{user.email}
|
||||
</MuiLink>
|
||||
</Box>
|
||||
{user.admin == true&& (
|
||||
<Box sx={{ flexShrink: 0, ml: 1 }}>
|
||||
<Chip
|
||||
label="Admin"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: ADMIN_COLORS.magenta,
|
||||
color: ADMIN_COLORS.hoverBackground,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2, borderColor: ADMIN_COLORS.border }} />
|
||||
|
||||
<List disablePadding>
|
||||
<ListItem sx={{ py: 0.5, px: 0 }}>
|
||||
<ListItemText
|
||||
primary="Status"
|
||||
secondary={user.admin ? "Administrator" : "User"}
|
||||
primaryTypographyProps={{
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'medium',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
color: user.admin ? ADMIN_COLORS.magenta : ADMIN_COLORS.secondary,
|
||||
fontWeight: 'medium',
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem sx={{ py: 0.5, px: 0 }}>
|
||||
<ListItemText
|
||||
primary="Created"
|
||||
secondary={this.formatDate(user.created_at)}
|
||||
primaryTypographyProps={{
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'medium',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
color: ADMIN_COLORS.warning,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem sx={{ py: 0.5, px: 0 }}>
|
||||
<ListItemText
|
||||
primary="Orders"
|
||||
secondary={`${user.orderCount || 0} total`}
|
||||
primaryTypographyProps={{
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'medium',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
color: ADMIN_COLORS.warning,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
|
||||
|
||||
{/* All Orders */}
|
||||
{user.orders && user.orders.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 2, borderColor: ADMIN_COLORS.border }} />
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
...styles.primaryHeading
|
||||
}}
|
||||
>
|
||||
Orders
|
||||
</Typography>
|
||||
<List disablePadding>
|
||||
{user.orders
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) // Sort by newest first
|
||||
.map((order, orderIndex) => (
|
||||
<ListItem key={order.orderId || orderIndex} sx={{ py: 0.5, px: 0 }}>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 'medium',
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
>
|
||||
{order.orderId || 'N/A'}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={order.status || 'unknown'}
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
height: 'auto',
|
||||
py: 0.25,
|
||||
backgroundColor: this.getOrderStatusChipColor(order.status),
|
||||
color: ADMIN_COLORS.hoverBackground,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.5 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: ADMIN_COLORS.warning,
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
>
|
||||
{this.formatDate(order.created_at)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: ADMIN_COLORS.primaryBright,
|
||||
fontWeight: 'medium',
|
||||
fontFamily: ADMIN_COLORS.fontFamily
|
||||
}}
|
||||
>
|
||||
{order.totalCost ? this.formatPrice(order.totalCost) : 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Notification Snackbar */}
|
||||
<Snackbar
|
||||
open={this.state.notification.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={this.handleCloseNotification}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={this.handleCloseNotification}
|
||||
severity={this.state.notification.severity}
|
||||
sx={{
|
||||
width: '100%',
|
||||
backgroundColor: ADMIN_COLORS.surfaceBackground,
|
||||
border: `1px solid ${ADMIN_COLORS.border}`,
|
||||
color: ADMIN_COLORS.primaryText,
|
||||
fontFamily: ADMIN_COLORS.fontFamily,
|
||||
'& .MuiAlert-icon': {
|
||||
color: this.state.notification.severity === 'success'
|
||||
? ADMIN_COLORS.primary
|
||||
: this.state.notification.severity === 'error'
|
||||
? ADMIN_COLORS.error
|
||||
: ADMIN_COLORS.warning
|
||||
},
|
||||
'& .MuiAlert-action': {
|
||||
color: ADMIN_COLORS.primaryText
|
||||
}
|
||||
}}
|
||||
>
|
||||
{this.state.notification.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UsersPage;
|
||||
46
src/pages/Widerrufsrecht.js
Normal file
46
src/pages/Widerrufsrecht.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
import LegalPage from './LegalPage.js';
|
||||
|
||||
const Widerrufsrecht = () => {
|
||||
const content = (
|
||||
<>
|
||||
<Typography variant="body1" paragraph>
|
||||
Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Um Ihr Widerrufsrecht auszuüben, müssen Sie uns
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" sx={{ ml: 2 }} paragraph>
|
||||
Growheads<br />
|
||||
Trachenberger Straße 14<br />
|
||||
01129 Dresden<br />
|
||||
E-Mail: service@growheads.de
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief, per Telefax oder E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Folgen des Widerrufs
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet. Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist. Sie haben die Waren unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem Sie uns über den Widerruf dieses Vertrags unterrichten, an uns zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn Sie die Waren vor Ablauf der Frist von vierzehn Tagen absenden. Sie tragen die unmittelbaren Kosten der Rücksendung der Waren. Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Hinweis auf Nichtbestehen des Widerrufsrechts
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Das Widerrufsrecht besteht nicht für Waren die auf Kundenwunsch gefertigt oder zugeschnitten (Folien und Schläuche) wurden, kann aber nach Absprache gewährt werden. Düngerbehälter, bei denen das Verschlusssiegel entfernt oder durch Öffnen zerstört worden ist, sind ebenfalls vom Widerrufsrecht ausgeschlossen.
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
|
||||
return <LegalPage title="Widerrufsrecht" content={content} />;
|
||||
};
|
||||
|
||||
export default Widerrufsrecht;
|
||||
Reference in New Issue
Block a user