Genesis
This commit is contained in:
439
src/components/AddToCartButton.js
Normal file
439
src/components/AddToCartButton.js
Normal file
@@ -0,0 +1,439 @@
|
||||
import React, { Component } from "react";
|
||||
import Button from "@mui/material/Button";
|
||||
import ButtonGroup from "@mui/material/ButtonGroup";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import RemoveIcon from "@mui/icons-material/Remove";
|
||||
import ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
|
||||
if (!Array.isArray(window.cart)) window.cart = [];
|
||||
|
||||
class AddToCartButton extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
if (!Array.isArray(window.cart)) window.cart = [];
|
||||
this.state = {
|
||||
quantity: window.cart.find((i) => i.id === this.props.id)
|
||||
? window.cart.find((i) => i.id === this.props.id).quantity
|
||||
: 0,
|
||||
isEditing: false,
|
||||
editValue: "",
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.cart = () => {
|
||||
if (!Array.isArray(window.cart)) window.cart = [];
|
||||
const item = window.cart.find((i) => i.id === this.props.id);
|
||||
const newQuantity = item ? item.quantity : 0;
|
||||
if (this.state.quantity !== newQuantity)
|
||||
this.setState({ quantity: newQuantity });
|
||||
};
|
||||
window.addEventListener("cart", this.cart);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("cart", this.cart);
|
||||
}
|
||||
|
||||
handleIncrement = () => {
|
||||
if (!window.cart) window.cart = [];
|
||||
const idx = window.cart.findIndex((item) => item.id === this.props.id);
|
||||
if (idx === -1) {
|
||||
window.cart.push({
|
||||
id: this.props.id,
|
||||
name: this.props.name,
|
||||
seoName: this.props.seoName,
|
||||
pictureList: this.props.pictureList,
|
||||
price: this.props.price,
|
||||
quantity: 1,
|
||||
weight: this.props.weight,
|
||||
vat: this.props.vat,
|
||||
versandklasse: this.props.versandklasse,
|
||||
availableSupplier: this.props.availableSupplier,
|
||||
available: this.props.available
|
||||
});
|
||||
} else {
|
||||
window.cart[idx].quantity++;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
};
|
||||
|
||||
handleDecrement = () => {
|
||||
if (!window.cart) window.cart = [];
|
||||
const idx = window.cart.findIndex((item) => item.id === this.props.id);
|
||||
if (idx !== -1) {
|
||||
if (window.cart[idx].quantity > 1) {
|
||||
window.cart[idx].quantity--;
|
||||
} else {
|
||||
window.cart.splice(idx, 1);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
}
|
||||
};
|
||||
|
||||
handleClearCart = () => {
|
||||
if (!window.cart) window.cart = [];
|
||||
const idx = window.cart.findIndex((item) => item.id === this.props.id);
|
||||
if (idx !== -1) {
|
||||
window.cart.splice(idx, 1);
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
}
|
||||
};
|
||||
|
||||
handleEditStart = () => {
|
||||
this.setState({
|
||||
isEditing: true,
|
||||
editValue: this.state.quantity > 0 ? this.state.quantity.toString() : "",
|
||||
});
|
||||
};
|
||||
|
||||
handleEditChange = (event) => {
|
||||
// Only allow numbers
|
||||
const value = event.target.value.replace(/[^0-9]/g, "");
|
||||
this.setState({ editValue: value });
|
||||
};
|
||||
|
||||
handleEditComplete = () => {
|
||||
let newQuantity = parseInt(this.state.editValue, 10);
|
||||
if (isNaN(newQuantity) || newQuantity < 0) {
|
||||
newQuantity = 0;
|
||||
}
|
||||
if (!window.cart) window.cart = [];
|
||||
const idx = window.cart.findIndex((item) => item.id === this.props.id);
|
||||
if (idx !== -1) {
|
||||
window.cart[idx].quantity = newQuantity;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("cart", {
|
||||
detail: { id: this.props.id, quantity: newQuantity },
|
||||
})
|
||||
);
|
||||
}
|
||||
this.setState({ isEditing: false });
|
||||
};
|
||||
|
||||
handleKeyPress = (event) => {
|
||||
if (event.key === "Enter") {
|
||||
this.handleEditComplete();
|
||||
}
|
||||
};
|
||||
|
||||
toggleCart = () => {
|
||||
// Dispatch an event that Header.js can listen for to toggle the cart
|
||||
window.dispatchEvent(new CustomEvent("toggle-cart"));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { quantity, isEditing, editValue } = this.state;
|
||||
const { available, size, incoming, availableSupplier } = this.props;
|
||||
|
||||
// Button is disabled if product is not available
|
||||
if (!available) {
|
||||
if (incoming) {
|
||||
return (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size={size || "medium"}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
backgroundColor: "#ffeb3b",
|
||||
color: "#000000",
|
||||
"&:hover": {
|
||||
backgroundColor: "#fdd835",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Ab{" "}
|
||||
{new Date(incoming).toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// If availableSupplier is 1, handle both quantity cases
|
||||
if (availableSupplier === 1) {
|
||||
// If no items in cart, show simple "Add to Cart" button with yellowish green
|
||||
if (quantity === 0) {
|
||||
return (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size={size || "medium"}
|
||||
onClick={this.handleIncrement}
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
backgroundColor: "#9ccc65", // yellowish green
|
||||
color: "#000000",
|
||||
"&:hover": {
|
||||
backgroundColor: "#8bc34a",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{this.props.steckling ? "Als Steckling vorbestellen" : "In den Korb"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// If items are in cart, show quantity controls with yellowish green
|
||||
if (quantity > 0) {
|
||||
return (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size={size || "medium"}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
"& .MuiButtonGroup-grouped:not(:last-of-type)": {
|
||||
borderRight: "1px solid rgba(255,255,255,0.3)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleDecrement}
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
px: 2,
|
||||
flexGrow: 2,
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={this.handleEditStart}
|
||||
>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
autoFocus
|
||||
value={editValue}
|
||||
onChange={this.handleEditChange}
|
||||
onBlur={this.handleEditComplete}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onFocus={(e) => e.target.select()}
|
||||
size="small"
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
style: {
|
||||
textAlign: "center",
|
||||
width: "30px",
|
||||
fontSize: "14px",
|
||||
padding: "2px",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
"aria-label": "quantity",
|
||||
}}
|
||||
sx={{ my: -0.5 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="button" sx={{ fontWeight: "bold" }}>
|
||||
{quantity}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleIncrement}
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
|
||||
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleClearCart}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "error.light" },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{this.props.cartButton && (
|
||||
<Tooltip title="Warenkorb öffnen" arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "primary.light" },
|
||||
}}
|
||||
>
|
||||
<ShoppingCartIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size={size || "medium"}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Out of Stock
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// If no items in cart, show simple "Add to Cart" button
|
||||
if (quantity === 0) {
|
||||
return (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size={size || "medium"}
|
||||
onClick={this.handleIncrement}
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontWeight: "bold",
|
||||
"&:hover": {
|
||||
backgroundColor: "primary.dark",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{this.props.steckling ? "Als Steckling vorbestellen" : "In den Korb"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// If items are in cart, show quantity controls
|
||||
return (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size={size || "medium"}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
"& .MuiButtonGroup-grouped:not(:last-of-type)": {
|
||||
borderRight: "1px solid rgba(255,255,255,0.3)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleDecrement}
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
px: 2,
|
||||
flexGrow: 2,
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={this.handleEditStart}
|
||||
>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
autoFocus
|
||||
value={editValue}
|
||||
onChange={this.handleEditChange}
|
||||
onBlur={this.handleEditComplete}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onFocus={(e) => e.target.select()}
|
||||
size="small"
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
style: {
|
||||
textAlign: "center",
|
||||
width: "30px",
|
||||
fontSize: "14px",
|
||||
padding: "2px",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
"aria-label": "quantity",
|
||||
}}
|
||||
sx={{ my: -0.5 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="button" sx={{ fontWeight: "bold" }}>
|
||||
{quantity}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleIncrement}
|
||||
sx={{ width: "28px", borderRadius: 0, flexGrow: 1 }}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
|
||||
<Tooltip title="Aus dem Warenkorb entfernen" arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.handleClearCart}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "error.light" },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{this.props.cartButton && (
|
||||
<Tooltip title="Warenkorb öffnen" arrow>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
"&:hover": { color: "primary.light" },
|
||||
}}
|
||||
>
|
||||
<ShoppingCartIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AddToCartButton;
|
||||
226
src/components/CartDropdown.js
Normal file
226
src/components/CartDropdown.js
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import List from '@mui/material/List';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import CartItem from './CartItem.js';
|
||||
|
||||
|
||||
class CartDropdown extends Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
cartItems = [],
|
||||
onClose,
|
||||
onCheckout,
|
||||
showDetailedSummary = false,
|
||||
deliveryMethod = '',
|
||||
deliveryCost = 0
|
||||
} = this.props;
|
||||
|
||||
// Calculate the total weight of all items in the cart
|
||||
const totalWeight = cartItems.reduce((sum, item) => {
|
||||
const weightPerItem = item.weight || 0;
|
||||
const quantity = item.quantity || 1;
|
||||
return sum + weightPerItem * quantity;
|
||||
}, 0);
|
||||
|
||||
// Calculate price breakdowns
|
||||
const priceCalculations = cartItems.reduce((acc, item) => {
|
||||
const totalItemPrice = item.price * item.quantity;
|
||||
const netPrice = totalItemPrice / (1 + item.vat / 100);
|
||||
const vatAmount = totalItemPrice - netPrice;
|
||||
|
||||
acc.totalGross += totalItemPrice;
|
||||
acc.totalNet += netPrice;
|
||||
|
||||
if (item.vat === 7) {
|
||||
acc.vat7 += vatAmount;
|
||||
} else if (item.vat === 19) {
|
||||
acc.vat19 += vatAmount;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
|
||||
|
||||
// Calculate detailed summary with shipping (similar to OrderSummary)
|
||||
const currencyFormatter = new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
});
|
||||
|
||||
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
|
||||
const shippingVat = deliveryCost - shippingNetPrice;
|
||||
const totalVat7 = priceCalculations.vat7;
|
||||
const totalVat19 = priceCalculations.vat19 + shippingVat;
|
||||
const totalGross = priceCalculations.totalGross + deliveryCost;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ bgcolor: 'primary.main', color: 'white', p: 2 }}>
|
||||
<Typography variant="h6">
|
||||
{cartItems.length} {cartItems.length === 1 ? 'Produkt' : 'Produkte'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{ cartItems && (
|
||||
<>
|
||||
<List sx={{ width: '100%' }}>
|
||||
{cartItems.map((item) => (
|
||||
<CartItem
|
||||
key={item.id}
|
||||
socket={this.props.socket}
|
||||
item={item}
|
||||
id={item.id}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{/* Display total weight if greater than 0 */}
|
||||
{totalWeight > 0 && (
|
||||
<Typography variant="subtitle2" sx={{ px: 2, mb: 1 }}>
|
||||
Gesamtgewicht: {totalWeight.toFixed(2)} kg
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Price breakdown table */}
|
||||
{cartItems.length > 0 && (
|
||||
<Box sx={{ px: 2, mb: 2 }}>
|
||||
{showDetailedSummary ? (
|
||||
// Detailed summary with shipping costs
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
Bestellübersicht
|
||||
</Typography>
|
||||
{deliveryMethod && (
|
||||
<Typography variant="body2" sx={{ mb: 1, color: 'text.secondary' }}>
|
||||
Versandart: {deliveryMethod}
|
||||
</Typography>
|
||||
)}
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Waren (netto):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(priceCalculations.totalNet)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>Versandkosten (netto):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(shippingNetPrice)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{totalVat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat7)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{totalVat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat19)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{currencyFormatter.format(priceCalculations.totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Versandkosten:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{currencyFormatter.format(deliveryCost)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
|
||||
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>Gesamtsumme:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
||||
{currencyFormatter.format(totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
) : (
|
||||
// Simple summary without shipping costs
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Gesamtnettopreis:</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalNet)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{priceCalculations.vat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat7)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{priceCalculations.vat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>19% Mehrwertsteuer:</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.vat19)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtbruttopreis ohne Versand:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(priceCalculations.totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={onClose}
|
||||
>
|
||||
Weiter einkaufen
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onCheckout && cartItems.length > 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
onClick={onCheckout}
|
||||
>
|
||||
Weiter zur Kasse
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CartDropdown;
|
||||
162
src/components/CartItem.js
Normal file
162
src/components/CartItem.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { Component } from 'react';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AddToCartButton from './AddToCartButton.js';
|
||||
|
||||
class CartItem extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
if (!window.tinyPicCache) {
|
||||
window.tinyPicCache = {};
|
||||
}
|
||||
if(this.props.item && this.props.item.pictureList && this.props.item.pictureList.split(',').length > 0) {
|
||||
const picid = this.props.item.pictureList.split(',')[0];
|
||||
if(window.tinyPicCache[picid]){
|
||||
this.setState({image:window.tinyPicCache[picid],loading:false, error: false})
|
||||
}else{
|
||||
this.setState({image: null, loading: true, error: false});
|
||||
if(this.props.socket){
|
||||
this.props.socket.emit('getPic', { bildId:picid, size:'tiny' }, (res) => {
|
||||
if(res.success){
|
||||
window.tinyPicCache[picid] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
this.setState({image: window.tinyPicCache[picid], loading: false});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleIncrement = () => {
|
||||
const { item, onQuantityChange } = this.props;
|
||||
onQuantityChange(item.quantity + 1);
|
||||
};
|
||||
|
||||
handleDecrement = () => {
|
||||
const { item, onQuantityChange } = this.props;
|
||||
if (item.quantity > 1) {
|
||||
onQuantityChange(item.quantity - 1);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { item } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
alignItems="flex-start"
|
||||
sx={{ py: 2, width: '100%' }}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
alt={item.name}
|
||||
src={this.state?.image}
|
||||
sx={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
mr: 2,
|
||||
bgcolor: 'primary.light',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, width: '100%' }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="div"
|
||||
sx={{ fontWeight: 'bold', mb: 0.5 }}
|
||||
>
|
||||
<Link to={`/Artikel/${item.seoName}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
{item.name}
|
||||
</Link>
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1, mt: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
component="div"
|
||||
>
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(item.price)} x {item.quantity}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="primary.dark"
|
||||
fontWeight="bold"
|
||||
component="div"
|
||||
>
|
||||
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(item.price * item.quantity)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Weight and VAT display - conditional layout based on weight */}
|
||||
{(item.weight > 0 || item.vat) && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: item.weight > 0 || (item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos') ? 'space-between' : 'flex-end',
|
||||
mb: 1
|
||||
}}>
|
||||
{item.weight > 0 && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
component="div"
|
||||
>
|
||||
{item.weight.toFixed(1).replace('.',',')} kg
|
||||
</Typography>
|
||||
)}
|
||||
{item.versandklasse && item.versandklasse != 'standard' && item.versandklasse != 'kostenlos' && (
|
||||
<Typography variant="body2" color="warning.main" fontWeight="medium" component="div">
|
||||
{item.versandklasse}
|
||||
</Typography>
|
||||
)}
|
||||
{item.vat && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
fontStyle="italic"
|
||||
component="div"
|
||||
>
|
||||
inkl. {new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(
|
||||
(item.price * item.quantity) - ((item.price * item.quantity) / (1 + item.vat / 100))
|
||||
)} MwSt. ({item.vat}%)
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ width: '250px'}}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
mb: 1,
|
||||
display: "block"
|
||||
}}
|
||||
>
|
||||
{this.props.id.toString().endsWith("steckling") ? "Lieferzeit: 14 Tage" :
|
||||
item.available == 1 ? "Lieferzeit: 2-3 Tage" :
|
||||
item.availableSupplier == 1 ? "Lieferzeit: 7-9 Tage" : ""}
|
||||
</Typography>
|
||||
<AddToCartButton available={1} id={this.props.id} availableSupplier={item.availableSupplier} price={item.price} seoName={item.seoName} name={item.name} weight={item.weight} vat={item.vat} versandklasse={item.versandklasse}/>
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CartItem;
|
||||
117
src/components/CartSyncDialog.js
Normal file
117
src/components/CartSyncDialog.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useState } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import RadioGroup from '@mui/material/RadioGroup';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
const CartSyncDialog = ({ open, localCart = [], serverCart = [], onClose, onConfirm }) => {
|
||||
const [option, setOption] = useState('merge');
|
||||
|
||||
// Helper function to determine if an item is selected in the result
|
||||
const isItemSelected = (item, cart, isResultCart = false) => {
|
||||
if (isResultCart) return true; // All items in result cart are selected
|
||||
|
||||
switch (option) {
|
||||
case 'deleteServer':
|
||||
return cart === localCart;
|
||||
case 'useServer':
|
||||
return cart === serverCart;
|
||||
case 'merge':
|
||||
return true; // Both carts contribute to merge
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const renderCartItem = (item, cart, isResultCart = false) => {
|
||||
const selected = isItemSelected(item, cart, isResultCart);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
sx={{
|
||||
opacity: selected ? 1 : 0.4,
|
||||
backgroundColor: selected ? 'action.selected' : 'transparent',
|
||||
borderRadius: 1,
|
||||
mb: 0.5
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{item.name} x {item.quantity}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
|
||||
<DialogTitle>Warenkorb-Synchronisierung</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography paragraph>
|
||||
Sie haben einen gespeicherten Warenkorb in ihrem Account. Bitte wählen Sie, wie Sie verfahren möchten:
|
||||
</Typography>
|
||||
<RadioGroup value={option} onChange={e => setOption(e.target.value)}>
|
||||
{/*<FormControlLabel
|
||||
value="useLocalArchive"
|
||||
control={<Radio />}
|
||||
label="Lokalen Warenkorb verwenden und Serverseitigen Warenkorb archivieren"
|
||||
/>*/}
|
||||
<FormControlLabel
|
||||
value="deleteServer"
|
||||
control={<Radio />}
|
||||
label="Server-Warenkorb löschen"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="useServer"
|
||||
control={<Radio />}
|
||||
label="Server-Warenkorb übernehmen"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="merge"
|
||||
control={<Radio />}
|
||||
label="Warenkörbe zusammenführen"
|
||||
/>
|
||||
</RadioGroup>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="h6">Ihr aktueller Warenkorb</Typography>
|
||||
<List sx={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
{localCart.length > 0
|
||||
? localCart.map(item => renderCartItem(item, localCart))
|
||||
: <Typography color="text.secondary" sx={{ p: 2 }}>leer</Typography>}
|
||||
</List>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="h6">In Ihrem Profil gespeicherter Warenkorb</Typography>
|
||||
<List sx={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
{serverCart.length > 0
|
||||
? serverCart.map(item => renderCartItem(item, serverCart))
|
||||
: <Typography color="text.secondary" sx={{ p: 2 }}>leer</Typography>}
|
||||
</List>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Abbrechen</Button>
|
||||
<Button variant="contained" onClick={() => onConfirm(option)}>
|
||||
Weiter
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartSyncDialog;
|
||||
201
src/components/CategoryBox.js
Normal file
201
src/components/CategoryBox.js
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
|
||||
// @note SwashingtonCP font is now loaded globally via index.css
|
||||
|
||||
// Initialize cache in window object if it doesn't exist
|
||||
if (typeof window !== 'undefined' && !window.categoryImageCache) {
|
||||
window.categoryImageCache = new Map();
|
||||
}
|
||||
|
||||
const CategoryBox = ({
|
||||
id,
|
||||
name,
|
||||
seoName,
|
||||
bgcolor,
|
||||
fontSize = '0.8rem',
|
||||
...props
|
||||
}) => {
|
||||
const [imageUrl, setImageUrl] = useState(null);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const socket = useContext(SocketContext);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl = null;
|
||||
|
||||
// Skip image loading entirely if prerender fallback is active
|
||||
// @note Check both browser and SSR environments for prerender flag
|
||||
const isPrerenderFallback = (typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
|
||||
(typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__);
|
||||
|
||||
if (isPrerenderFallback) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have the image data cached first
|
||||
if (typeof window !== 'undefined' && window.categoryImageCache.has(id)) {
|
||||
const cachedImageData = window.categoryImageCache.get(id);
|
||||
if (cachedImageData === null) {
|
||||
// @note Cached as null - this category has no image
|
||||
setImageUrl(null);
|
||||
setImageError(false);
|
||||
} else {
|
||||
// Create fresh blob URL from cached binary data
|
||||
try {
|
||||
const uint8Array = new Uint8Array(cachedImageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
} catch (error) {
|
||||
console.error('Error creating blob URL from cached data:', error);
|
||||
setImageError(true);
|
||||
setImageUrl(null);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If socket is available and connected, fetch the image
|
||||
if (socket && socket.connected && id && !isLoading) {
|
||||
setIsLoading(true);
|
||||
|
||||
socket.emit('getCategoryPic', { categoryId: id }, (response) => {
|
||||
setIsLoading(false);
|
||||
|
||||
if (response.success) {
|
||||
const imageData = response.image; // Binary image data or null
|
||||
|
||||
if (imageData) {
|
||||
try {
|
||||
// Convert binary data to blob URL
|
||||
const uint8Array = new Uint8Array(imageData);
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
setImageError(false);
|
||||
|
||||
// @note Cache the raw binary data in window object (not the blob URL)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.categoryImageCache.set(id, imageData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting image data to URL:', error);
|
||||
setImageError(true);
|
||||
setImageUrl(null);
|
||||
// Cache as null to avoid repeated requests
|
||||
if (typeof window !== 'undefined') {
|
||||
window.categoryImageCache.set(id, null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// @note No image available for this category
|
||||
setImageUrl(null);
|
||||
setImageError(false);
|
||||
// Cache as null so we don't keep requesting
|
||||
if (typeof window !== 'undefined') {
|
||||
window.categoryImageCache.set(id, null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Error fetching category image:', response.error);
|
||||
setImageError(true);
|
||||
setImageUrl(null);
|
||||
// Cache as null to avoid repeated failed requests
|
||||
if (typeof window !== 'undefined') {
|
||||
window.categoryImageCache.set(id, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up the object URL when component unmounts or image changes
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [socket, socket?.connected, id, isLoading]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
component={Link}
|
||||
to={`/Kategorie/${seoName}`}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
width: '130px',
|
||||
height: '130px',
|
||||
minHeight: '130px',
|
||||
minWidth: '130px',
|
||||
maxWidth: '130px',
|
||||
maxHeight: '130px',
|
||||
display: 'block',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
backgroundColor: bgcolor || '#f0f0f0',
|
||||
boxShadow: '0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)'
|
||||
}}
|
||||
sx={{
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: 8
|
||||
},
|
||||
...props.sx
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* Main content area - using flex to fill space */}
|
||||
<Box sx={{
|
||||
width: '130px',
|
||||
height: '130px',
|
||||
bgcolor: bgcolor || '#e0e0e0',
|
||||
position: 'relative',
|
||||
backgroundImage: ((typeof window !== 'undefined' && window.__PRERENDER_FALLBACK__) ||
|
||||
(typeof global !== 'undefined' && global.window && global.window.__PRERENDER_FALLBACK__))
|
||||
? `url("/assets/images/cat${id}.jpg")`
|
||||
: (imageUrl && !imageError ? `url("${imageUrl}")` : 'none'),
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat'
|
||||
}}>
|
||||
|
||||
{/* Category name at bottom */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '0px',
|
||||
left: '0px',
|
||||
width: '130px',
|
||||
height: '40px',
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
display: 'table',
|
||||
tableLayout: 'fixed'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'table-cell',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
color: 'white',
|
||||
fontSize: fontSize,
|
||||
fontFamily: 'SwashingtonCP, "Times New Roman", Georgia, serif',
|
||||
fontWeight: 'normal',
|
||||
lineHeight: '1.2',
|
||||
padding: '0 8px'
|
||||
}}>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryBox;
|
||||
68
src/components/CategoryBoxGrid.js
Normal file
68
src/components/CategoryBoxGrid.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import CategoryBox from './CategoryBox.js';
|
||||
|
||||
// @note SwashingtonCP font is now loaded globally via index.css
|
||||
|
||||
const CategoryBoxGrid = ({
|
||||
categories = [],
|
||||
title,
|
||||
spacing = 3,
|
||||
showTitle = true,
|
||||
titleVariant = 'h3',
|
||||
titleSx = {},
|
||||
gridProps = {},
|
||||
boxProps = {}
|
||||
}) => {
|
||||
if (!categories || categories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Optional title */}
|
||||
{showTitle && title && (
|
||||
<Typography
|
||||
variant={titleVariant}
|
||||
component="h1"
|
||||
sx={{
|
||||
mb: 4,
|
||||
fontFamily: 'SwashingtonCP',
|
||||
color: 'primary.main',
|
||||
textAlign: 'center',
|
||||
...titleSx
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Category boxes grid */}
|
||||
<Grid container spacing={spacing} sx={{ mt: showTitle && title ? 0 : 2, ...gridProps.sx }} {...gridProps}>
|
||||
{categories.map((category) => (
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
lg={3}
|
||||
key={category.id}
|
||||
>
|
||||
<CategoryBox
|
||||
id={category.id}
|
||||
name={category.name}
|
||||
seoName={category.seoName}
|
||||
image={category.image}
|
||||
bgcolor={category.bgcolor}
|
||||
{...boxProps}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryBoxGrid;
|
||||
664
src/components/ChatAssistant.js
Normal file
664
src/components/ChatAssistant.js
Normal file
@@ -0,0 +1,664 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
|
||||
import parse, { domToReact } from 'html-react-parser';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { isUserLoggedIn } from './LoginComponent.js';
|
||||
// Initialize window object for storing messages
|
||||
if (!window.chatMessages) {
|
||||
window.chatMessages = [];
|
||||
}
|
||||
|
||||
class ChatAssistant extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const privacyConfirmed = sessionStorage.getItem('privacyConfirmed') === 'true';
|
||||
|
||||
this.state = {
|
||||
messages: window.chatMessages,
|
||||
inputValue: '',
|
||||
isTyping: false,
|
||||
isRecording: false,
|
||||
recordingTime: 0,
|
||||
mediaRecorder: null,
|
||||
audioChunks: [],
|
||||
aiThink: false,
|
||||
atDatabase: false,
|
||||
atWeb: false,
|
||||
privacyConfirmed: privacyConfirmed,
|
||||
isGuest: false
|
||||
};
|
||||
|
||||
this.messagesEndRef = React.createRef();
|
||||
this.fileInputRef = React.createRef();
|
||||
this.recordingTimer = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Add socket listeners if socket is available and connected
|
||||
this.addSocketListeners();
|
||||
|
||||
const userStatus = isUserLoggedIn();
|
||||
const isGuest = !userStatus.isLoggedIn;
|
||||
|
||||
if (isGuest && !this.state.privacyConfirmed) {
|
||||
this.setState(prevState => {
|
||||
if (prevState.messages.find(msg => msg.id === 'privacy-prompt')) {
|
||||
return { isGuest: true };
|
||||
}
|
||||
|
||||
const privacyMessage = {
|
||||
id: 'privacy-prompt',
|
||||
sender: 'bot',
|
||||
text: 'Bitte bestätigen Sie, dass Sie die <a href="/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> gelesen haben und damit einverstanden sind. <button data-confirm-privacy="true">Gelesen & Akzeptiert</button>',
|
||||
};
|
||||
const updatedMessages = [privacyMessage, ...prevState.messages];
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isGuest: true
|
||||
};
|
||||
});
|
||||
} else {
|
||||
this.setState({ isGuest });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected) {
|
||||
// Socket just connected, add listeners
|
||||
this.addSocketListeners();
|
||||
} else if (wasConnected && !isNowConnected) {
|
||||
// Socket just disconnected, remove listeners
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.removeSocketListeners();
|
||||
this.stopRecording();
|
||||
if (this.recordingTimer) {
|
||||
clearInterval(this.recordingTimer);
|
||||
}
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('aiassyResponse', this.handleBotResponse);
|
||||
this.props.socket.on('aiassyStatus', this.handleStateResponse);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('aiassyResponse', this.handleBotResponse);
|
||||
this.props.socket.off('aiassyStatus', this.handleStateResponse);
|
||||
}
|
||||
}
|
||||
|
||||
handleBotResponse = (msgId,response) => {
|
||||
this.setState(prevState => {
|
||||
// Check if a message with this msgId already exists
|
||||
const existingMessageIndex = prevState.messages.findIndex(msg => msg.msgId === msgId);
|
||||
|
||||
let updatedMessages;
|
||||
|
||||
if (existingMessageIndex !== -1 && msgId) {
|
||||
// If message with this msgId exists, append the response
|
||||
updatedMessages = [...prevState.messages];
|
||||
updatedMessages[existingMessageIndex] = {
|
||||
...updatedMessages[existingMessageIndex],
|
||||
text: updatedMessages[existingMessageIndex].text + response.content
|
||||
};
|
||||
} else {
|
||||
// Create a new message
|
||||
console.log('ChatAssistant: handleBotResponse', msgId, response);
|
||||
if(response && response.content) {
|
||||
const newBotMessage = {
|
||||
id: Date.now(),
|
||||
msgId: msgId,
|
||||
sender: 'bot',
|
||||
text: response.content,
|
||||
};
|
||||
updatedMessages = [...prevState.messages, newBotMessage];
|
||||
}
|
||||
}
|
||||
|
||||
// Store in window object
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isTyping: false
|
||||
};
|
||||
});
|
||||
}
|
||||
handleStateResponse = (msgId,response) => {
|
||||
if(response == 'think') this.setState({ aiThink: true });
|
||||
if(response == 'nothink') this.setState({ aiThink: false });
|
||||
if(response == 'database') this.setState({ atDatabase: true });
|
||||
if(response == 'nodatabase') this.setState({ atDatabase: false });
|
||||
if(response == 'web') this.setState({ atWeb: true });
|
||||
if(response == 'noweb') this.setState({ atWeb: false });
|
||||
}
|
||||
|
||||
scrollToBottom = () => {
|
||||
this.messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
handleInputChange = (event) => {
|
||||
this.setState({ inputValue: event.target.value });
|
||||
}
|
||||
|
||||
handleSendMessage = () => {
|
||||
const userMessage = this.state.inputValue.trim();
|
||||
if (!userMessage) return;
|
||||
|
||||
const newUserMessage = {
|
||||
id: Date.now(),
|
||||
sender: 'user',
|
||||
text: userMessage,
|
||||
};
|
||||
|
||||
// Update messages in component state
|
||||
this.setState(prevState => {
|
||||
const updatedMessages = [...prevState.messages, newUserMessage];
|
||||
// Store in window object
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
inputValue: '',
|
||||
isTyping: true
|
||||
};
|
||||
}, () => {
|
||||
// Emit message to socket server after state is updated
|
||||
if (userMessage.trim() && this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyMessage', userMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleKeyDown = (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
this.handleSendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
const audioChunks = [];
|
||||
|
||||
mediaRecorder.addEventListener("dataavailable", event => {
|
||||
audioChunks.push(event.data);
|
||||
});
|
||||
|
||||
mediaRecorder.addEventListener("stop", () => {
|
||||
if (audioChunks.length > 0) {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
|
||||
this.sendAudioMessage(audioBlob);
|
||||
}
|
||||
|
||||
// Stop all tracks on the stream to release the microphone
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
});
|
||||
|
||||
// Start recording
|
||||
mediaRecorder.start();
|
||||
|
||||
// Set up timer - limit to 60 seconds
|
||||
this.recordingTimer = setInterval(() => {
|
||||
this.setState(prevState => {
|
||||
const newTime = prevState.recordingTime + 1;
|
||||
|
||||
// Auto-stop after 10 seconds
|
||||
if (newTime >= 10) {
|
||||
this.stopRecording();
|
||||
}
|
||||
|
||||
return { recordingTime: newTime };
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
this.setState({
|
||||
isRecording: true,
|
||||
mediaRecorder,
|
||||
audioChunks,
|
||||
recordingTime: 0
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error accessing microphone:", err);
|
||||
alert("Could not access microphone. Please check your browser permissions.");
|
||||
}
|
||||
};
|
||||
|
||||
stopRecording = () => {
|
||||
const { mediaRecorder, isRecording } = this.state;
|
||||
|
||||
if (this.recordingTimer) {
|
||||
clearInterval(this.recordingTimer);
|
||||
}
|
||||
|
||||
if (mediaRecorder && isRecording) {
|
||||
mediaRecorder.stop();
|
||||
this.setState({
|
||||
isRecording: false,
|
||||
recordingTime: 0
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
sendAudioMessage = async (audioBlob) => {
|
||||
// Create a URL for the audio blob
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
// Create a user message with audio content
|
||||
const newUserMessage = {
|
||||
id: Date.now(),
|
||||
sender: 'user',
|
||||
text: `<audio controls src="${audioUrl}"></audio>`,
|
||||
isAudio: true
|
||||
};
|
||||
|
||||
// Update UI with the audio message
|
||||
this.setState(prevState => {
|
||||
const updatedMessages = [...prevState.messages, newUserMessage];
|
||||
// Store in window object
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isTyping: true
|
||||
};
|
||||
});
|
||||
|
||||
// Convert audio to base64 for sending to server
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(audioBlob);
|
||||
reader.onloadend = () => {
|
||||
const base64Audio = reader.result.split(',')[1];
|
||||
// Send audio data to server
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyAudioMessage', {
|
||||
audio: base64Audio,
|
||||
format: 'wav'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
handleImageUpload = () => {
|
||||
this.fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
handleFileChange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
this.resizeAndSendImage(file);
|
||||
}
|
||||
// Reset the file input
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
resizeAndSendImage = (file) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// Calculate new dimensions (max 450px width/height)
|
||||
const maxSize = 450;
|
||||
let { width, height } = img;
|
||||
|
||||
if (width > height) {
|
||||
if (width > maxSize) {
|
||||
height *= maxSize / width;
|
||||
width = maxSize;
|
||||
}
|
||||
} else {
|
||||
if (height > maxSize) {
|
||||
width *= maxSize / height;
|
||||
height = maxSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Set canvas dimensions
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Draw and compress image
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Convert to blob with compression
|
||||
canvas.toBlob((blob) => {
|
||||
this.sendImageMessage(blob);
|
||||
}, 'image/jpeg', 0.8);
|
||||
};
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
};
|
||||
|
||||
sendImageMessage = async (imageBlob) => {
|
||||
// Create a URL for the image blob
|
||||
const imageUrl = URL.createObjectURL(imageBlob);
|
||||
|
||||
// Create a user message with image content
|
||||
const newUserMessage = {
|
||||
id: Date.now(),
|
||||
sender: 'user',
|
||||
text: `<img src="${imageUrl}" alt="Uploaded image" style="max-width: 100%; height: auto; border-radius: 8px;" />`,
|
||||
isImage: true
|
||||
};
|
||||
|
||||
// Update UI with the image message
|
||||
this.setState(prevState => {
|
||||
const updatedMessages = [...prevState.messages, newUserMessage];
|
||||
// Store in window object
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isTyping: true
|
||||
};
|
||||
});
|
||||
|
||||
// Convert image to base64 for sending to server
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(imageBlob);
|
||||
reader.onloadend = () => {
|
||||
const base64Image = reader.result.split(',')[1];
|
||||
// Send image data to server
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit('aiassyPicMessage', {
|
||||
image: base64Image,
|
||||
format: 'jpeg'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
formatTime = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
||||
};
|
||||
|
||||
handlePrivacyConfirm = () => {
|
||||
sessionStorage.setItem('privacyConfirmed', 'true');
|
||||
this.setState(prevState => {
|
||||
const updatedMessages = prevState.messages.filter(msg => msg.id !== 'privacy-prompt');
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
privacyConfirmed: true,
|
||||
messages: updatedMessages
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
formatMarkdown = (text) => {
|
||||
// Replace code blocks with formatted HTML
|
||||
return text.replace(/```(.*?)\n([\s\S]*?)```/g, (match, language, code) => {
|
||||
return `<pre class="code-block" data-language="${language.trim()}"><code>${code.trim()}</code></pre>`;
|
||||
});
|
||||
};
|
||||
|
||||
getParseOptions = () => ({
|
||||
replace: (domNode) => {
|
||||
// Convert <a> tags to React Router Links
|
||||
if (domNode.name === 'a' && domNode.attribs && domNode.attribs.href) {
|
||||
const href = domNode.attribs.href;
|
||||
|
||||
// Only convert internal links (not external URLs)
|
||||
if (!href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('//')) {
|
||||
return (
|
||||
<Link to={href} style={{ color: 'inherit', textDecoration: 'underline' }}>
|
||||
{domToReact(domNode.children, this.getParseOptions())}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Style pre/code blocks
|
||||
if (domNode.name === 'pre' && domNode.attribs && domNode.attribs.class === 'code-block') {
|
||||
const language = domNode.attribs['data-language'] || '';
|
||||
return (
|
||||
<pre style={{
|
||||
backgroundColor: '#c0f5c0',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
overflowX: 'auto',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.9em',
|
||||
whiteSpace: 'pre-wrap',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
{language && <div style={{ marginBottom: '4px', color: '#666' }}>{language}</div>}
|
||||
{domToReact(domNode.children, this.getParseOptions())}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (domNode.name === 'button' && domNode.attribs && domNode.attribs['data-confirm-privacy']) {
|
||||
return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>Gelesen & Akzeptiert</Button>;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
render() {
|
||||
const { open, onClose } = this.props;
|
||||
const { messages, inputValue, isTyping, isRecording, recordingTime, isGuest, privacyConfirmed } = this.state;
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputsDisabled = isGuest && !privacyConfirmed;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: { xs: 16, sm: 80 },
|
||||
right: { xs: 16, sm: 16 },
|
||||
left: { xs: 16, sm: 'auto' },
|
||||
top: { xs: 16, sm: 'auto' },
|
||||
width: { xs: 'calc(100vw - 32px)', sm: 450, md: 600, lg: 750 },
|
||||
height: { xs: 'calc(100vh - 32px)', sm: 600, md: 650, lg: 700 },
|
||||
maxWidth: { xs: 'none', sm: 450, md: 600, lg: 750 },
|
||||
maxHeight: { xs: 'calc(100vh - 72px)', sm: 600, md: 650, lg: 700 },
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 1300,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
borderTopLeftRadius: 'inherit',
|
||||
borderTopRightRadius: 'inherit',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="div">
|
||||
Assistent
|
||||
<Typography component="span" color={this.state.aiThink ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🧠</Typography>
|
||||
<Typography component="span" color={this.state.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography>
|
||||
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
|
||||
</Typography>
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: 'primary.contrastText' }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{messages &&messages.map((message) => (
|
||||
<Box
|
||||
key={message.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: message.sender === 'user' ? 'flex-end' : 'flex-start',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{message.sender === 'bot' && (
|
||||
<Avatar sx={{ bgcolor: 'primary.main', width: 30, height: 30 }}>
|
||||
<SmartToyIcon fontSize="small" />
|
||||
</Avatar>
|
||||
)}
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
py: 1,
|
||||
px: 3,
|
||||
borderRadius: 2,
|
||||
bgcolor: message.sender === 'user' ? 'secondary.light' : 'grey.200',
|
||||
maxWidth: '75%',
|
||||
fontSize: '0.8em'
|
||||
}}
|
||||
>
|
||||
{message.text ? parse(this.formatMarkdown(message.text), this.getParseOptions()) : ''}
|
||||
</Paper>
|
||||
{message.sender === 'user' && (
|
||||
<Avatar sx={{ bgcolor: 'secondary.main', width: 30, height: 30 }}>
|
||||
<PersonIcon fontSize="small" />
|
||||
</Avatar>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{isTyping && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar sx={{ bgcolor: 'primary.main', width: 30, height: 30 }}>
|
||||
<SmartToyIcon fontSize="small" />
|
||||
</Avatar>
|
||||
<Paper elevation={1} sx={{ p: 1, borderRadius: 2, bgcolor: 'grey.200', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<CircularProgress size={16} sx={{ mx: 1 }} />
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
<div ref={this.messagesEndRef} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
p: 1,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={this.fileInputRef}
|
||||
accept="image/*"
|
||||
onChange={this.handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."}
|
||||
value={inputValue}
|
||||
onChange={this.handleInputChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
disabled={isRecording || inputsDisabled}
|
||||
slotProps={{
|
||||
input: {
|
||||
maxLength: 300,
|
||||
endAdornment: isRecording && (
|
||||
<Typography variant="caption" color="primary" sx={{ mr: 1 }}>
|
||||
{this.formatTime(recordingTime)}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{isRecording ? (
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={this.stopRecording}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<StopIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={this.startRecording}
|
||||
sx={{ ml: 1 }}
|
||||
disabled={isTyping || inputsDisabled}
|
||||
>
|
||||
<MicIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={this.handleImageUpload}
|
||||
sx={{ ml: 1 }}
|
||||
disabled={isTyping || isRecording || inputsDisabled}
|
||||
>
|
||||
<PhotoCameraIcon />
|
||||
</IconButton>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ ml: 1 }}
|
||||
onClick={this.handleSendMessage}
|
||||
disabled={isTyping || isRecording || inputsDisabled}
|
||||
>
|
||||
Senden
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatAssistant;
|
||||
681
src/components/Content.js
Normal file
681
src/components/Content.js
Normal file
@@ -0,0 +1,681 @@
|
||||
import React, { Component } from 'react';
|
||||
import Container from '@mui/material/Container';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Link } from 'react-router-dom';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import ProductFilters from './ProductFilters.js';
|
||||
import ProductList from './ProductList.js';
|
||||
import CategoryBoxGrid from './CategoryBoxGrid.js';
|
||||
import CategoryBox from './CategoryBox.js';
|
||||
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// @note SwashingtonCP font is now loaded globally via index.css
|
||||
|
||||
const withRouter = (ClassComponent) => {
|
||||
return (props) => {
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
return <ClassComponent {...props} params={params} searchParams={searchParams} />;
|
||||
};
|
||||
};
|
||||
|
||||
function getCachedCategoryData(categoryId) {
|
||||
if (!window.productCache) {
|
||||
window.productCache = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `categoryProducts_${categoryId}`;
|
||||
const cachedData = window.productCache[cacheKey];
|
||||
|
||||
if (cachedData) {
|
||||
const { timestamp } = cachedData;
|
||||
const cacheAge = Date.now() - timestamp;
|
||||
const tenMinutes = 10 * 60 * 1000;
|
||||
if (cacheAge < tenMinutes) {
|
||||
return cachedData;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error reading from cache:', err);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function getFilteredProducts(unfilteredProducts, attributes) {
|
||||
const attributeSettings = getAllSettingsWithPrefix('filter_attribute_');
|
||||
const manufacturerSettings = getAllSettingsWithPrefix('filter_manufacturer_');
|
||||
const availabilitySettings = getAllSettingsWithPrefix('filter_availability_');
|
||||
|
||||
const attributeFilters = [];
|
||||
Object.keys(attributeSettings).forEach(key => {
|
||||
if (attributeSettings[key] === 'true') {
|
||||
attributeFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
const manufacturerFilters = [];
|
||||
Object.keys(manufacturerSettings).forEach(key => {
|
||||
if (manufacturerSettings[key] === 'true') {
|
||||
manufacturerFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
const availabilityFilters = [];
|
||||
Object.keys(availabilitySettings).forEach(key => {
|
||||
if (availabilitySettings[key] === 'true') {
|
||||
availabilityFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueAttributes = [...new Set((attributes || []).map(attr => attr.kMerkmalWert ? attr.kMerkmalWert.toString() : ''))];
|
||||
const uniqueManufacturers = [...new Set((unfilteredProducts || []).filter(product => product.manufacturerId).map(product => product.manufacturerId ? product.manufacturerId.toString() : ''))];
|
||||
const uniqueManufacturersWithName = [...new Set((unfilteredProducts || []).filter(product => product.manufacturerId).map(product => ({id:product.manufacturerId ? product.manufacturerId.toString() : '',value:product.manufacturer})))];
|
||||
const activeAttributeFilters = attributeFilters.filter(filter => uniqueAttributes.includes(filter));
|
||||
const activeManufacturerFilters = manufacturerFilters.filter(filter => uniqueManufacturers.includes(filter));
|
||||
const attributeFiltersByGroup = {};
|
||||
for (const filterId of activeAttributeFilters) {
|
||||
const attribute = attributes.find(attr => attr.kMerkmalWert.toString() === filterId);
|
||||
if (attribute) {
|
||||
if (!attributeFiltersByGroup[attribute.cName]) {
|
||||
attributeFiltersByGroup[attribute.cName] = [];
|
||||
}
|
||||
attributeFiltersByGroup[attribute.cName].push(filterId);
|
||||
}
|
||||
}
|
||||
|
||||
let filteredProducts = (unfilteredProducts || []).filter(product => {
|
||||
const availabilityFilter = sessionStorage.getItem('filter_availability');
|
||||
let inStockMatch = availabilityFilter == 1 ? true : (product.available>0);
|
||||
const isNewMatch = availabilityFilters.includes('2') ? isNew(product.neu) : true;
|
||||
let soonMatch = availabilityFilters.includes('3') ? !product.available && product.incoming : true;
|
||||
|
||||
const soon2Match = (availabilityFilter != 1)&&availabilityFilters.includes('3') ? (product.available) || (!product.available && product.incoming) : true;
|
||||
if( (availabilityFilter != 1)&&availabilityFilters.includes('3') && ((product.available) || (!product.available && product.incoming))){
|
||||
inStockMatch = true;
|
||||
soonMatch = true;
|
||||
console.log("soon2Match", product.cName);
|
||||
}
|
||||
|
||||
const manufacturerMatch = activeManufacturerFilters.length === 0 ||
|
||||
|
||||
(product.manufacturerId && activeManufacturerFilters.includes(product.manufacturerId.toString()));
|
||||
if (Object.keys(attributeFiltersByGroup).length === 0) {
|
||||
return manufacturerMatch && soon2Match && inStockMatch && soonMatch && isNewMatch;
|
||||
}
|
||||
const productAttributes = attributes
|
||||
.filter(attr => attr.kArtikel === product.id);
|
||||
const attributeMatch = Object.entries(attributeFiltersByGroup).every(([groupName, groupFilters]) => {
|
||||
const productGroupAttributes = productAttributes
|
||||
.filter(attr => attr.cName === groupName)
|
||||
.map(attr => attr.kMerkmalWert ? attr.kMerkmalWert.toString() : '');
|
||||
return groupFilters.some(filter => productGroupAttributes.includes(filter));
|
||||
});
|
||||
return manufacturerMatch && attributeMatch && soon2Match && inStockMatch && soonMatch && isNewMatch;
|
||||
});
|
||||
|
||||
|
||||
const activeAttributeFiltersWithNames = activeAttributeFilters.map(filter => {
|
||||
const attribute = attributes.find(attr => attr.kMerkmalWert.toString() === filter);
|
||||
return {name: attribute.cName, value: attribute.cWert, id: attribute.kMerkmalWert};
|
||||
});
|
||||
const activeManufacturerFiltersWithNames = activeManufacturerFilters.map(filter => {
|
||||
const manufacturer = uniqueManufacturersWithName.find(manufacturer => manufacturer.id === filter);
|
||||
return {name: manufacturer.value, value: manufacturer.id};
|
||||
});
|
||||
return {filteredProducts,activeAttributeFilters:activeAttributeFiltersWithNames,activeManufacturerFilters:activeManufacturerFiltersWithNames};
|
||||
}
|
||||
function setCachedCategoryData(categoryId, data) {
|
||||
if (!window.productCache) {
|
||||
window.productCache = {};
|
||||
}
|
||||
if (!window.productDetailCache) {
|
||||
window.productDetailCache = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `categoryProducts_${categoryId}`;
|
||||
if(data.products) for(const product of data.products) {
|
||||
window.productDetailCache[product.id] = product;
|
||||
}
|
||||
window.productCache[cacheKey] = {
|
||||
...data,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error writing to cache:', err);
|
||||
}
|
||||
}
|
||||
|
||||
class Content extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loaded: false,
|
||||
categoryName: null,
|
||||
unfilteredProducts: [],
|
||||
filteredProducts: [],
|
||||
attributes: [],
|
||||
childCategories: []
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if(this.props.params.categoryId) {this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
})}
|
||||
else if (this.props.searchParams?.get('q')) {
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if(this.props.params.categoryId && (prevProps.params.categoryId !== this.props.params.categoryId)) {
|
||||
window.currentSearchQuery = null;
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
});
|
||||
}
|
||||
else if (this.props.searchParams?.get('q') && (prevProps.searchParams?.get('q') !== this.props.searchParams?.get('q'))) {
|
||||
this.setState({loaded: false, unfilteredProducts: [], filteredProducts: [], attributes: [], categoryName: null, childCategories: []}, () => {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
})
|
||||
}
|
||||
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected && !this.state.loaded) {
|
||||
// Socket just connected and we haven't loaded data yet, retry loading
|
||||
if (this.props.params.categoryId) {
|
||||
this.fetchCategoryData(this.props.params.categoryId);
|
||||
} else if (this.props.searchParams?.get('q')) {
|
||||
this.fetchSearchData(this.props.searchParams?.get('q'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processData(response) {
|
||||
const unfilteredProducts = response.products;
|
||||
|
||||
if (!window.individualProductCache) {
|
||||
window.individualProductCache = {};
|
||||
}
|
||||
//console.log("processData", unfilteredProducts);
|
||||
if(unfilteredProducts) unfilteredProducts.forEach(product => {
|
||||
window.individualProductCache[product.id] = {
|
||||
data: product,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
});
|
||||
|
||||
this.setState({
|
||||
unfilteredProducts: unfilteredProducts,
|
||||
...getFilteredProducts(
|
||||
unfilteredProducts,
|
||||
response.attributes
|
||||
),
|
||||
categoryName: response.categoryName || response.name || null,
|
||||
dataType: response.dataType,
|
||||
dataParam: response.dataParam,
|
||||
attributes: response.attributes,
|
||||
childCategories: response.childCategories || [],
|
||||
loaded: true
|
||||
});
|
||||
}
|
||||
|
||||
fetchCategoryData(categoryId) {
|
||||
const cachedData = getCachedCategoryData(categoryId);
|
||||
if (cachedData) {
|
||||
this.processDataWithCategoryTree(cachedData, categoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch category data");
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit("getCategoryProducts", { categoryId: categoryId },
|
||||
(response) => {
|
||||
setCachedCategoryData(categoryId, response);
|
||||
if (response && response.products !== undefined) {
|
||||
this.processDataWithCategoryTree(response, categoryId);
|
||||
} else {
|
||||
console.log("fetchCategoryData in Content failed", response);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
processDataWithCategoryTree(response, categoryId) {
|
||||
// Get child categories from the cached category tree
|
||||
let childCategories = [];
|
||||
try {
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (categoryTreeCache && categoryTreeCache.categoryTree) {
|
||||
// If categoryId is a string (SEO name), find by seoName, otherwise by ID
|
||||
const targetCategory = typeof categoryId === 'string'
|
||||
? this.findCategoryBySeoName(categoryTreeCache.categoryTree, categoryId)
|
||||
: this.findCategoryById(categoryTreeCache.categoryTree, categoryId);
|
||||
|
||||
if (targetCategory && targetCategory.children) {
|
||||
childCategories = targetCategory.children;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error getting child categories from tree:', err);
|
||||
}
|
||||
|
||||
// Add child categories to the response
|
||||
const enhancedResponse = {
|
||||
...response,
|
||||
childCategories
|
||||
};
|
||||
|
||||
this.processData(enhancedResponse);
|
||||
}
|
||||
|
||||
findCategoryById(category, targetId) {
|
||||
if (!category) return null;
|
||||
|
||||
if (category.id === targetId) {
|
||||
return category;
|
||||
}
|
||||
|
||||
if (category.children) {
|
||||
for (let child of category.children) {
|
||||
const found = this.findCategoryById(child, targetId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fetchSearchData(query) {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch search data");
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit("getSearchProducts", { query },
|
||||
(response) => {
|
||||
if (response && response.products) {
|
||||
this.processData(response);
|
||||
} else {
|
||||
console.log("fetchSearchData in Content failed", response);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
filterProducts() {
|
||||
this.setState({
|
||||
...getFilteredProducts(
|
||||
this.state.unfilteredProducts,
|
||||
this.state.attributes
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to find category by seoName
|
||||
findCategoryBySeoName = (categoryNode, seoName) => {
|
||||
if (!categoryNode) return null;
|
||||
|
||||
if (categoryNode.seoName === seoName) {
|
||||
return categoryNode;
|
||||
}
|
||||
|
||||
if (categoryNode.children) {
|
||||
for (const child of categoryNode.children) {
|
||||
const found = this.findCategoryBySeoName(child, seoName);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to get current category ID from seoName
|
||||
getCurrentCategoryId = () => {
|
||||
const seoName = this.props.params.categoryId;
|
||||
|
||||
// Get the category tree from cache
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the category by seoName
|
||||
const category = this.findCategoryBySeoName(categoryTreeCache.categoryTree, seoName);
|
||||
return category ? category.id : null;
|
||||
}
|
||||
|
||||
renderParentCategoryNavigation = () => {
|
||||
const currentCategoryId = this.getCurrentCategoryId();
|
||||
if (!currentCategoryId) return null;
|
||||
|
||||
// Get the category tree from cache
|
||||
const categoryTreeCache = window.productCache && window.productCache['categoryTree_209'];
|
||||
if (!categoryTreeCache || !categoryTreeCache.categoryTree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the current category in the tree
|
||||
const currentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategoryId);
|
||||
if (!currentCategory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this category has a parent (not root category 209)
|
||||
if (!currentCategory.parentId || currentCategory.parentId === 209) {
|
||||
return null; // Don't show for top-level categories
|
||||
}
|
||||
|
||||
// Find the parent category
|
||||
const parentCategory = this.findCategoryById(categoryTreeCache.categoryTree, currentCategory.parentId);
|
||||
if (!parentCategory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create parent category object for CategoryBox
|
||||
const parentCategoryForDisplay = {
|
||||
id: parentCategory.id,
|
||||
seoName: parentCategory.seoName,
|
||||
name: parentCategory.name,
|
||||
image: parentCategory.image,
|
||||
isParentNav: true
|
||||
};
|
||||
|
||||
return parentCategoryForDisplay;
|
||||
}
|
||||
|
||||
render() {
|
||||
// Check if we should show category boxes instead of product list
|
||||
const showCategoryBoxes = this.state.loaded &&
|
||||
this.state.unfilteredProducts.length === 0 &&
|
||||
this.state.childCategories.length > 0;
|
||||
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 2, flexGrow: 1, display: 'grid', gridTemplateRows: '1fr' }}>
|
||||
|
||||
{showCategoryBoxes ? (
|
||||
// Show category boxes layout when no products but have child categories
|
||||
<CategoryBoxGrid
|
||||
categories={this.state.childCategories}
|
||||
title={this.state.categoryName}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Show subcategories above main layout when there are both products and child categories */}
|
||||
{this.state.loaded &&
|
||||
this.state.unfilteredProducts.length > 0 &&
|
||||
this.state.childCategories.length > 0 && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
{(() => {
|
||||
const parentCategory = this.renderParentCategoryNavigation();
|
||||
if (parentCategory) {
|
||||
// Show parent category to the left of subcategories
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 3, flexWrap: 'wrap' }}>
|
||||
{/* Parent Category Box */}
|
||||
<Box sx={{ mt:2,position: 'relative', flexShrink: 0 }}>
|
||||
<CategoryBox
|
||||
id={parentCategory.id}
|
||||
seoName={parentCategory.seoName}
|
||||
name={parentCategory.name}
|
||||
image={parentCategory.image}
|
||||
height={130}
|
||||
fontSize="1.0rem"
|
||||
/>
|
||||
{/* Up Arrow Overlay */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
bgcolor: 'rgba(27, 94, 32, 0.8)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 100,
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Subcategories Grid */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<CategoryBoxGrid
|
||||
categories={this.state.childCategories}
|
||||
showTitle={false}
|
||||
spacing={3}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
// Just show subcategories without parent
|
||||
return (
|
||||
<CategoryBoxGrid
|
||||
categories={this.state.childCategories}
|
||||
showTitle={false}
|
||||
spacing={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Show parent category navigation when in 2nd or 3rd level but no subcategories */}
|
||||
{this.state.loaded &&
|
||||
this.props.params.categoryId &&
|
||||
!(this.state.unfilteredProducts.length > 0 && this.state.childCategories.length > 0) && (() => {
|
||||
const parentCategory = this.renderParentCategoryNavigation();
|
||||
if (parentCategory) {
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ position: 'relative', width: 'fit-content' }}>
|
||||
<CategoryBox
|
||||
id={parentCategory.id}
|
||||
seoName={parentCategory.seoName}
|
||||
name={parentCategory.name}
|
||||
image={parentCategory.image}
|
||||
height={130}
|
||||
fontSize="1.0rem"
|
||||
/>
|
||||
{/* Up Arrow Overlay */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
bgcolor: 'rgba(27, 94, 32, 0.8)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 100,
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<KeyboardArrowUpIcon sx={{ color: 'white', fontSize: '1.2rem' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Show normal product list layout */}
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 2fr', md: '1fr 3fr', lg: '1fr 4fr', xl: '1fr 4fr' },
|
||||
gap: 3
|
||||
}}>
|
||||
|
||||
<Stack direction="row" spacing={0} sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: { xs: 'min-content', sm: '100%' }
|
||||
}}>
|
||||
|
||||
<Box >
|
||||
|
||||
<ProductFilters
|
||||
products={this.state.unfilteredProducts}
|
||||
filteredProducts={this.state.filteredProducts}
|
||||
attributes={this.state.attributes}
|
||||
searchParams={this.props.searchParams}
|
||||
onFilterChange={()=>{this.filterProducts()}}
|
||||
dataType={this.state.dataType}
|
||||
dataParam={this.state.dataParam}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{(this.getCurrentCategoryId() == 706 || this.getCurrentCategoryId() == 689) &&
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
|
||||
<Typography variant="h6" sx={{mt:3}}>
|
||||
Andere Kategorien
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
|
||||
{this.props.params.categoryId == 'Stecklinge' && <Paper
|
||||
component={Link}
|
||||
to="/Kategorie/Seeds"
|
||||
sx={{
|
||||
p:0,
|
||||
mt: 1,
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
height: 300,
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: 10,
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: 20
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Image Container - Place your seeds image here */}
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
bgcolor: '#e1f0d3',
|
||||
backgroundImage: 'url("/assets/images/seeds.jpg")',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* Overlay text - optional */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bgcolor: 'rgba(27, 94, 32, 0.8)',
|
||||
p: 2,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
|
||||
Seeds
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
}
|
||||
|
||||
{this.props.params.categoryId == 'Seeds' && <Paper
|
||||
component={Link}
|
||||
to="/Kategorie/Stecklinge"
|
||||
sx={{
|
||||
p: 0,
|
||||
mt: 1,
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
height: 300,
|
||||
boxShadow: 10,
|
||||
transition: 'all 0.3s ease',
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: 20
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Image Container - Place your cutlings image here */}
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
bgcolor: '#e8f5d6',
|
||||
backgroundImage: 'url("/assets/images/cutlings.jpg")',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* Overlay text - optional */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bgcolor: 'rgba(27, 94, 32, 0.8)',
|
||||
p: 2,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '1.3rem', color: 'white', fontFamily: 'SwashingtonCP' }}>
|
||||
Stecklinge
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>}
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<ProductList
|
||||
socket={this.props.socket}
|
||||
totalProductCount={(this.state.unfilteredProducts || []).length}
|
||||
products={this.state.filteredProducts || []}
|
||||
activeAttributeFilters={this.state.activeAttributeFilters || []}
|
||||
activeManufacturerFilters={this.state.activeManufacturerFilters || []}
|
||||
onFilterChange={()=>{this.filterProducts()}}
|
||||
dataType={this.state.dataType}
|
||||
dataParam={this.state.dataParam}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(Content);
|
||||
319
src/components/Filter.js
Normal file
319
src/components/Filter.js
Normal file
@@ -0,0 +1,319 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import { getAllSettingsWithPrefix } from '../utils/sessionStorage.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
class Filter extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const options = this.initializeOptions(props);
|
||||
const counts = this.initializeCounts(props,options);
|
||||
this.state = {
|
||||
options,
|
||||
counts,
|
||||
isCollapsed: true // Start collapsed on xs screens
|
||||
};
|
||||
}
|
||||
|
||||
initializeCounts = (props,options) => {
|
||||
const counts = {};
|
||||
|
||||
if(props.filterType === 'availability'){
|
||||
const products = options[1] ? props.products : props.products;
|
||||
if(products) for(const product of products){
|
||||
if(product.available) counts[1] = (counts[1] || 0) + 1;
|
||||
if(isNew(product.neu)) counts[2] = (counts[2] || 0) + 1;
|
||||
if(!product.available && product.incoming) counts[3] = (counts[3] || 0) + 1;
|
||||
}
|
||||
}
|
||||
if(props.filterType === 'manufacturer'){
|
||||
const uniqueManufacturers = [...new Set(props.products.filter(product => product.manufacturerId).map(product => product.manufacturerId))];
|
||||
const filteredManufacturers = uniqueManufacturers.filter(manufacturerId => options[manufacturerId] === true);
|
||||
const products = filteredManufacturers.length > 0 ? props.products : props.filteredProducts;
|
||||
for(const product of products){
|
||||
counts[product.manufacturerId] = (counts[product.manufacturerId] || 0) + 1;
|
||||
}
|
||||
}
|
||||
if(props.filterType === 'attribute'){
|
||||
//console.log('countCaclulation for attribute filter',props.title,this.props.title);
|
||||
const optionIds = props.options.map(option => option.id);
|
||||
//console.log('optionIds',optionIds);
|
||||
const attributeCount = {};
|
||||
for(const attribute of props.attributes){
|
||||
attributeCount[attribute.kMerkmalWert] = (attributeCount[attribute.kMerkmalWert] || 0) + 1;
|
||||
}
|
||||
const uniqueProductIds = props.filteredProducts.map(product => product.id);
|
||||
const attributesFilteredByUniqueAttributeProducts = props.attributes.filter(attribute => uniqueProductIds.includes(attribute.kArtikel));
|
||||
const attributeCountFiltered = {};
|
||||
for(const attribute of attributesFilteredByUniqueAttributeProducts){
|
||||
attributeCountFiltered[attribute.kMerkmalWert] = (attributeCountFiltered[attribute.kMerkmalWert] || 0) + 1;
|
||||
}
|
||||
let oneIsSelected = false;
|
||||
for(const option of optionIds) if(options[option]) oneIsSelected = true;
|
||||
for(const option of props.options){
|
||||
counts[option.id] = oneIsSelected?attributeCount[option.id]:attributeCountFiltered[option.id];
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
initializeOptions = (props) => {
|
||||
|
||||
if(props.filterType === 'attribute'){
|
||||
const attributeFilters = [];
|
||||
const attributeSettings = getAllSettingsWithPrefix('filter_attribute_');
|
||||
|
||||
Object.keys(attributeSettings).forEach(key => {
|
||||
if (attributeSettings[key] === 'true') {
|
||||
attributeFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
return attributeFilters.reduce((acc, filter) => {
|
||||
acc[filter] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
if(props.filterType === 'manufacturer'){
|
||||
const manufacturerFilters = [];
|
||||
const manufacturerSettings = getAllSettingsWithPrefix('filter_manufacturer_');
|
||||
|
||||
Object.keys(manufacturerSettings).forEach(key => {
|
||||
if (manufacturerSettings[key] === 'true') {
|
||||
manufacturerFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
return manufacturerFilters.reduce((acc, filter) => {
|
||||
acc[filter] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
if(props.filterType === 'availability'){
|
||||
const availabilityFilter = sessionStorage.getItem('filter_availability');
|
||||
const newFilters = [];
|
||||
const soonFilters = [];
|
||||
const availabilitySettings = getAllSettingsWithPrefix('filter_availability_');
|
||||
|
||||
Object.keys(availabilitySettings).forEach(key => {
|
||||
if (availabilitySettings[key] === 'true') {
|
||||
if(key.split('_')[2] == '2') newFilters.push(key.split('_')[2]);
|
||||
if(key.split('_')[2] == '3') soonFilters.push(key.split('_')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
//console.log('newFilters',newFilters);
|
||||
const optionsState = {};
|
||||
if(!availabilityFilter) optionsState['1'] = true;
|
||||
if(newFilters.length > 0) optionsState['2'] = true;
|
||||
if(soonFilters.length > 0) optionsState['3'] = true;
|
||||
|
||||
const inStock = props.searchParams?.get('inStock');
|
||||
if(inStock) optionsState[inStock] = true;
|
||||
return optionsState;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// make this more fine grained with dependencies on props
|
||||
|
||||
if((prevProps.products !== this.props.products) || (prevProps.filteredProducts !== this.props.filteredProducts) || (prevProps.options !== this.props.options) || (prevProps.attributes !== this.props.attributes)){
|
||||
const options = this.initializeOptions(this.props);
|
||||
const counts = this.initializeCounts(this.props,options);
|
||||
this.setState({
|
||||
options,
|
||||
counts
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleOptionChange = (event) => {
|
||||
const { name, checked } = event.target;
|
||||
|
||||
// Update local state first to ensure immediate UI feedback
|
||||
this.setState(prevState => ({
|
||||
options: {
|
||||
...prevState.options,
|
||||
[name]: checked
|
||||
}
|
||||
}));
|
||||
|
||||
// Then notify the parent component
|
||||
if (this.props.onFilterChange) {
|
||||
this.props.onFilterChange({
|
||||
type: this.props.filterType || 'default',
|
||||
name: name,
|
||||
value: checked
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
resetFilters = () => {
|
||||
// Reset current filter's state
|
||||
const emptyOptions = {};
|
||||
Object.keys(this.state.options).forEach(option => {
|
||||
emptyOptions[option] = false;
|
||||
});
|
||||
|
||||
this.setState({ options: emptyOptions });
|
||||
|
||||
// Notify parent component to reset ALL filters (including other filter components)
|
||||
if (this.props.onFilterChange) {
|
||||
this.props.onFilterChange({
|
||||
type: 'RESET_ALL_FILTERS',
|
||||
resetAll: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
toggleCollapse = () => {
|
||||
this.setState(prevState => ({
|
||||
isCollapsed: !prevState.isCollapsed
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { options, counts, isCollapsed } = this.state;
|
||||
const { title, options: optionsList = [] } = this.props;
|
||||
|
||||
// Check if we're on xs screen size
|
||||
const isXsScreen = window.innerWidth < 600;
|
||||
|
||||
const tableStyle = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse'
|
||||
};
|
||||
|
||||
const cellStyle = {
|
||||
padding: '0px 0',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: '1'
|
||||
};
|
||||
|
||||
const checkboxCellStyle = {
|
||||
...cellStyle,
|
||||
width: '20px',
|
||||
verticalAlign: 'middle',
|
||||
paddingRight: '8px'
|
||||
};
|
||||
|
||||
const labelCellStyle = {
|
||||
...cellStyle,
|
||||
cursor: 'pointer',
|
||||
verticalAlign: 'middle',
|
||||
userSelect: 'none'
|
||||
};
|
||||
|
||||
const countCellStyle = {
|
||||
...cellStyle,
|
||||
textAlign: 'right',
|
||||
color: 'rgba(0, 0, 0, 0.6)',
|
||||
fontSize: '1rem',
|
||||
verticalAlign: 'middle'
|
||||
};
|
||||
|
||||
const countBoxStyle = {
|
||||
display: 'inline-block',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.7rem',
|
||||
minWidth: '16px',
|
||||
textAlign: 'center',
|
||||
color: 'rgba(0, 0, 0, 0.7)'
|
||||
};
|
||||
|
||||
const resetButtonStyle = {
|
||||
padding: '2px 8px',
|
||||
fontSize: '0.7rem',
|
||||
backgroundColor: '#f0f0f0',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
color: 'rgba(0, 0, 0, 0.7)',
|
||||
float: 'right'
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: { xs: 'pointer', sm: 'default' }
|
||||
}}
|
||||
onClick={isXsScreen ? this.toggleCollapse : undefined}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="medium" gutterBottom={!isXsScreen}>
|
||||
{title}
|
||||
{/* Only show reset button on Availability filter */}
|
||||
{title === "VerfügbarkeitDISABLED" && (
|
||||
<button
|
||||
style={resetButtonStyle}
|
||||
onClick={this.resetFilters}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</Typography>
|
||||
{isXsScreen && (
|
||||
<IconButton size="small" sx={{ p: 0 }}>
|
||||
{isCollapsed ? <ExpandMoreIcon /> : <ExpandLessIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Collapse in={!isXsScreen || !isCollapsed}>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<table style={tableStyle}>
|
||||
<tbody>
|
||||
{optionsList.map((option) => (
|
||||
<tr key={option.id} style={{ height: '32px' }}>
|
||||
<td style={checkboxCellStyle}>
|
||||
<Checkbox
|
||||
checked={options[option.id] || false}
|
||||
onChange={this.handleOptionChange}
|
||||
name={option.id}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{
|
||||
padding: '0px',
|
||||
'& .MuiSvgIcon-root': { fontSize: 28 }
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td style={labelCellStyle} onClick={() => {
|
||||
const event = { target: { name: option.id, checked: !options[option.id] } };
|
||||
this.handleOptionChange(event);
|
||||
}}>
|
||||
{option.name}
|
||||
</td>
|
||||
<td style={countCellStyle}>
|
||||
{counts && counts[option.id] !== undefined && (
|
||||
<span style={countBoxStyle}>
|
||||
{counts[option.id]}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Filter;
|
||||
354
src/components/Footer.js
Normal file
354
src/components/Footer.js
Normal file
@@ -0,0 +1,354 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Link from '@mui/material/Link';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Paper from '@mui/material/Paper';
|
||||
|
||||
// Styled component for the router links
|
||||
const StyledRouterLink = styled(RouterLink)(() => ({
|
||||
color: 'inherit',
|
||||
fontSize: '13px',
|
||||
textDecoration: 'none',
|
||||
lineHeight: '1.5',
|
||||
display: 'block',
|
||||
padding: '4px 8px',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}));
|
||||
|
||||
// Styled component for the domain link
|
||||
const StyledDomainLink = styled(Link)(() => ({
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
lineHeight: '1.5',
|
||||
'&:hover': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
}));
|
||||
|
||||
// Styled component for the dark overlay
|
||||
const DarkOverlay = styled(Box)(() => ({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
zIndex: 9998,
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 0.9s ease',
|
||||
}));
|
||||
|
||||
// Styled component for the info bubble
|
||||
const InfoBubble = styled(Paper)(({ theme }) => ({
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
padding: theme.spacing(3),
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
minWidth: '280px',
|
||||
maxWidth: '400px',
|
||||
textAlign: 'center',
|
||||
transition: 'all 0.9s ease',
|
||||
}));
|
||||
|
||||
class Footer extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showMapsInfo: false,
|
||||
showReviewsInfo: false,
|
||||
};
|
||||
}
|
||||
|
||||
handleMapsMouseEnter = () => {
|
||||
this.setState({ showMapsInfo: true });
|
||||
};
|
||||
|
||||
handleMapsMouseLeave = () => {
|
||||
this.setState({ showMapsInfo: false });
|
||||
};
|
||||
|
||||
handleReviewsMouseEnter = () => {
|
||||
this.setState({ showReviewsInfo: true });
|
||||
};
|
||||
|
||||
handleReviewsMouseLeave = () => {
|
||||
this.setState({ showReviewsInfo: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { showMapsInfo, showReviewsInfo } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dark overlay for Maps */}
|
||||
<DarkOverlay sx={{
|
||||
opacity: showMapsInfo ? 1 : 0
|
||||
}} />
|
||||
|
||||
{/* Dark overlay for Reviews */}
|
||||
<DarkOverlay sx={{
|
||||
opacity: showReviewsInfo ? 1 : 0
|
||||
}} />
|
||||
|
||||
{/* Info bubble */}
|
||||
<InfoBubble
|
||||
elevation={8}
|
||||
sx={{
|
||||
opacity: showMapsInfo ? 1 : 0,
|
||||
visibility: showMapsInfo ? 'visible' : 'hidden',
|
||||
transform: showMapsInfo ? 'translate(-50%, -50%) scale(1)' : 'translate(-50%, -50%) scale(0.8)'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
color: 'primary.main',
|
||||
fontSize: '1.25rem'
|
||||
}}
|
||||
>
|
||||
Filiale
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 1,
|
||||
color: 'text.primary'
|
||||
}}
|
||||
>
|
||||
Öffnungszeiten:
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Mo-Fr 10-20
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Sa 11-19
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 1,
|
||||
color: 'text.primary'
|
||||
}}
|
||||
>
|
||||
Trachenberger Straße 14 - Dresden
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontStyle: 'italic',
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Zwischen Haltepunkt Pieschen und Trachenberger Platz
|
||||
</Typography>
|
||||
</InfoBubble>
|
||||
|
||||
{/* Reviews Info bubble */}
|
||||
<InfoBubble
|
||||
elevation={8}
|
||||
sx={{
|
||||
opacity: showReviewsInfo ? 1 : 0,
|
||||
visibility: showReviewsInfo ? 'visible' : 'hidden',
|
||||
transform: showReviewsInfo ? 'translate(-50%, -50%) scale(1)' : 'translate(-50%, -50%) scale(0.8)',
|
||||
width: 'auto',
|
||||
minWidth: 'auto',
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '90vh',
|
||||
padding: 2
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/reviews.jpg"
|
||||
alt="Customer Reviews"
|
||||
sx={{
|
||||
width: '861px',
|
||||
height: '371px',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '80vh',
|
||||
borderRadius: '8px',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
</InfoBubble>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
py: 2,
|
||||
px: 2,
|
||||
mt: 'auto',
|
||||
mb: 0,
|
||||
backgroundColor: 'primary.dark',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
sx={{ filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))',maxWidth: 'md', margin: 'auto' }}
|
||||
spacing={{ xs: 3, md: 2 }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'center', md: 'flex-end' }}
|
||||
>
|
||||
{/* Legal Links Section */}
|
||||
<Stack
|
||||
direction={{ xs: 'row', md: 'column' }}
|
||||
spacing={{ xs: 2, md: 0.5 }}
|
||||
justifyContent="center"
|
||||
alignItems={{ xs: 'center', md: 'left' }}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<StyledRouterLink to="/datenschutz">Datenschutz</StyledRouterLink>
|
||||
<StyledRouterLink to="/agb">AGB</StyledRouterLink>
|
||||
<StyledRouterLink to="/sitemap">Sitemap</StyledRouterLink>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'row', md: 'column' }}
|
||||
spacing={{ xs: 2, md: 0.5 }}
|
||||
justifyContent="center"
|
||||
alignItems={{ xs: 'center', md: 'left' }}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<StyledRouterLink to="/impressum">Impressum</StyledRouterLink>
|
||||
<StyledRouterLink to="/batteriegesetzhinweise">Batteriegesetzhinweise</StyledRouterLink>
|
||||
<StyledRouterLink to="/widerrufsrecht">Widerrufsrecht</StyledRouterLink>
|
||||
</Stack>
|
||||
|
||||
{/* Payment Methods Section */}
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={1}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={{ xs: 1, md: 2 }}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Box component="img" src="/assets/images/cards.png" alt="Cash" sx={{ height: { xs: 80, md: 95 } }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Google Services Badge Section */}
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={1}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={{ xs: 1, md: 2 }}
|
||||
sx={{pb: '10px'}}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Link
|
||||
href="https://reviewthis.biz/growheads"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
zIndex: 9999
|
||||
}}
|
||||
onMouseEnter={this.handleReviewsMouseEnter}
|
||||
onMouseLeave={this.handleReviewsMouseLeave}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/gg.png"
|
||||
alt="Google Reviews"
|
||||
sx={{
|
||||
height: { xs: 50, md: 60 },
|
||||
cursor: 'pointer',
|
||||
transition: 'all 2s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.5) translateY(-10px)'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://maps.app.goo.gl/D67ewDU3dZBda1BUA"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
zIndex: 9999
|
||||
}}
|
||||
onMouseEnter={this.handleMapsMouseEnter}
|
||||
onMouseLeave={this.handleMapsMouseLeave}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/images/maps.png"
|
||||
alt="Google Maps"
|
||||
sx={{
|
||||
height: { xs: 40, md: 50 },
|
||||
cursor: 'pointer',
|
||||
transition: 'all 2s ease',
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.5) translateY(-10px)',
|
||||
filter: 'drop-shadow(0 8px 16px rgba(0, 0, 0, 0.4))'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Copyright Section */}
|
||||
<Box sx={{ pb:'20px',textAlign: 'center', filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))', opacity: 0.7 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
||||
* Alle Preise inkl. gesetzlicher USt., zzgl. Versand
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: { xs: '11px', md: '14px' }, lineHeight: 1.5 }}>
|
||||
© {new Date().getFullYear()} <StyledDomainLink href="https://growheads.de" target="_blank" rel="noopener noreferrer">GrowHeads.de</StyledDomainLink>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
208
src/components/GoogleLoginButton.js
Normal file
208
src/components/GoogleLoginButton.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { Component } from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import GoogleAuthContext from '../contexts/GoogleAuthContext.js';
|
||||
|
||||
class GoogleLoginButton extends Component {
|
||||
static contextType = GoogleAuthContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isInitialized: false,
|
||||
isInitializing: false,
|
||||
promptShown: false,
|
||||
isPrompting: false // @note Added to prevent multiple simultaneous prompts
|
||||
};
|
||||
this.promptTimeout = null; // @note Added to track timeout
|
||||
this.prevContextLoaded = false; // @note Track previous context loaded state
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Check if Google libraries are already available
|
||||
const hasGoogleLoaded = window.google && window.google.accounts && window.google.accounts.id;
|
||||
const contextLoaded = this.context && this.context.isLoaded;
|
||||
|
||||
// @note Initialize the tracked context loaded state
|
||||
this.prevContextLoaded = contextLoaded;
|
||||
|
||||
// @note Only initialize immediately if context is already loaded, otherwise let componentDidUpdate handle it
|
||||
if (hasGoogleLoaded && this.context.clientId && contextLoaded) {
|
||||
this.initializeGoogleSignIn();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
// Initialize when all conditions are met and we haven't initialized before
|
||||
const hasGoogleLoaded = window.google && window.google.accounts && window.google.accounts.id;
|
||||
const contextLoaded = this.context && this.context.isLoaded;
|
||||
|
||||
// @note Only initialize when context becomes loaded for the first time
|
||||
if (!this.state.isInitialized &&
|
||||
!this.state.isInitializing &&
|
||||
hasGoogleLoaded &&
|
||||
this.context.clientId &&
|
||||
contextLoaded &&
|
||||
!this.prevContextLoaded) {
|
||||
this.initializeGoogleSignIn();
|
||||
}
|
||||
|
||||
// @note Update the tracked context loaded state
|
||||
this.prevContextLoaded = contextLoaded;
|
||||
|
||||
// Auto-prompt if initialization is complete and autoInitiate is true
|
||||
if (this.props.autoInitiate &&
|
||||
this.state.isInitialized &&
|
||||
!this.state.promptShown &&
|
||||
!this.state.isPrompting && // @note Added check to prevent multiple prompts
|
||||
(!prevState.isInitialized || !prevProps.autoInitiate)) {
|
||||
this.setState({ promptShown: true });
|
||||
this.schedulePrompt(100);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// @note Clear timeout on unmount to prevent memory leaks
|
||||
if (this.promptTimeout) {
|
||||
clearTimeout(this.promptTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
schedulePrompt = (delay = 0) => {
|
||||
// @note Clear any existing timeout
|
||||
if (this.promptTimeout) {
|
||||
clearTimeout(this.promptTimeout);
|
||||
}
|
||||
|
||||
this.promptTimeout = setTimeout(() => {
|
||||
this.tryPrompt();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
initializeGoogleSignIn = () => {
|
||||
// Avoid multiple initialization attempts
|
||||
if (this.state.isInitialized || this.state.isInitializing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isInitializing: true });
|
||||
|
||||
if (!window.google || !window.google.accounts || !window.google.accounts.id) {
|
||||
console.error('Google Sign-In API not loaded yet');
|
||||
this.setState({ isInitializing: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: this.context.clientId,
|
||||
callback: this.handleCredentialResponse,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
isInitialized: true,
|
||||
isInitializing: false
|
||||
}, () => {
|
||||
// Auto-prompt immediately if autoInitiate is true
|
||||
if (this.props.autoInitiate && !this.state.promptShown && !this.state.isPrompting) {
|
||||
this.setState({ promptShown: true });
|
||||
this.schedulePrompt(100);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Google Sign-In initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Google Sign-In:', error);
|
||||
this.setState({
|
||||
isInitializing: false
|
||||
});
|
||||
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleCredentialResponse = (response) => {
|
||||
console.log('cred',response);
|
||||
const { onSuccess, onError } = this.props;
|
||||
|
||||
// @note Reset prompting state when response is received
|
||||
this.setState({ isPrompting: false });
|
||||
|
||||
if (response && response.credential) {
|
||||
// Call onSuccess with the credential
|
||||
if (onSuccess) {
|
||||
onSuccess(response);
|
||||
}
|
||||
} else {
|
||||
// Call onError if something went wrong
|
||||
if (onError) {
|
||||
onError(new Error('Failed to get credential from Google'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
// @note Prevent multiple clicks while prompting
|
||||
if (this.state.isPrompting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If not initialized yet, try initializing first
|
||||
if (!this.state.isInitialized && !this.state.isInitializing) {
|
||||
this.initializeGoogleSignIn();
|
||||
// Add a small delay before attempting to prompt
|
||||
this.schedulePrompt(300);
|
||||
return;
|
||||
}
|
||||
|
||||
this.tryPrompt();
|
||||
};
|
||||
|
||||
tryPrompt = () => {
|
||||
// @note Prevent multiple simultaneous prompts
|
||||
if (this.state.isPrompting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.google || !window.google.accounts || !window.google.accounts.id) {
|
||||
console.error('Google Sign-In API not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.setState({ isPrompting: true });
|
||||
window.google.accounts.id.prompt();
|
||||
this.setState({ promptShown: true });
|
||||
console.log('Google Sign-In prompt displayed');
|
||||
} catch (error) {
|
||||
console.error('Error prompting Google Sign-In:', error);
|
||||
this.setState({ isPrompting: false });
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { disabled, style, className, text = 'Mit Google anmelden' } = this.props;
|
||||
const { isInitializing, isPrompting } = this.state;
|
||||
const isLoading = isInitializing || isPrompting || (this.context && !this.context.isLoaded);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<GoogleIcon />}
|
||||
onClick={this.handleClick}
|
||||
disabled={disabled || isLoading}
|
||||
style={{ backgroundColor: '#4285F4', color: 'white', ...style }}
|
||||
className={className}
|
||||
>
|
||||
{isLoading ? 'Loading...' : text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default GoogleLoginButton;
|
||||
99
src/components/Header.js
Normal file
99
src/components/Header.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { Component } from 'react';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import Container from '@mui/material/Container';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// Import extracted components
|
||||
import { Logo, SearchBar, ButtonGroupWithRouter, CategoryList } from './header/index.js';
|
||||
|
||||
// Main Header Component
|
||||
class Header extends Component {
|
||||
static contextType = SocketContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
cartItems: []
|
||||
};
|
||||
}
|
||||
|
||||
handleCartQuantityChange = (productId, quantity) => {
|
||||
this.setState(prevState => ({
|
||||
cartItems: prevState.cartItems.map(item =>
|
||||
item.id === productId ? { ...item, quantity } : item
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
handleCartRemoveItem = (productId) => {
|
||||
this.setState(prevState => ({
|
||||
cartItems: prevState.cartItems.filter(item => item.id !== productId)
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
// Get socket directly from context in render method
|
||||
const socket = this.context;
|
||||
const { isHomePage, isProfilePage } = this.props;
|
||||
|
||||
return (
|
||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ zIndex: 1100 }}>
|
||||
<Toolbar sx={{ minHeight: 64 }}>
|
||||
<Container maxWidth="lg" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{/* First row: Logo and ButtonGroup on xs, all items on larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexDirection: { xs: 'column', sm: 'row' }
|
||||
}}>
|
||||
{/* Top row for xs, single row for larger screens */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: { xs: 'space-between', sm: 'flex-start' }
|
||||
}}>
|
||||
<Logo />
|
||||
{/* SearchBar visible on sm and up */}
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' }, flexGrow: 1 }}>
|
||||
<SearchBar />
|
||||
</Box>
|
||||
<ButtonGroupWithRouter socket={socket}/>
|
||||
</Box>
|
||||
|
||||
{/* Second row: SearchBar only on xs */}
|
||||
<Box sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
width: '100%',
|
||||
mt: 1,mb: 1
|
||||
}}>
|
||||
<SearchBar />
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Toolbar>
|
||||
{(isHomePage || this.props.categoryId || isProfilePage) && <CategoryList categoryId={209} activeCategoryId={this.props.categoryId} socket={socket} />}
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Use a wrapper function to provide context
|
||||
const HeaderWithContext = (props) => {
|
||||
const location = useLocation();
|
||||
const isHomePage = location.pathname === '/';
|
||||
const isProfilePage = location.pathname === '/profile';
|
||||
|
||||
return (
|
||||
<SocketContext.Consumer>
|
||||
{socket => <Header {...props} socket={socket} isHomePage={isHomePage} isProfilePage={isProfilePage} />}
|
||||
</SocketContext.Consumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderWithContext;
|
||||
325
src/components/Images.js
Normal file
325
src/components/Images.js
Normal file
@@ -0,0 +1,325 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import LoupeIcon from '@mui/icons-material/Loupe';
|
||||
|
||||
class Images extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { mainPic:0,pics:[]};
|
||||
|
||||
console.log('Images constructor',props);
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.updatePics(0);
|
||||
}
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.fullscreenOpen !== this.props.fullscreenOpen) {
|
||||
this.updatePics();
|
||||
}
|
||||
}
|
||||
|
||||
updatePics = (newMainPic = this.state.mainPic) => {
|
||||
if (!window.tinyPicCache) window.tinyPicCache = {};
|
||||
if (!window.smallPicCache) window.smallPicCache = {};
|
||||
if (!window.mediumPicCache) window.mediumPicCache = {};
|
||||
if (!window.largePicCache) window.largePicCache = {};
|
||||
|
||||
if(this.props.pictureList && this.props.pictureList.length > 0){
|
||||
const bildIds = this.props.pictureList.split(',');
|
||||
|
||||
|
||||
const pics = [];
|
||||
const mainPicId = bildIds[newMainPic];
|
||||
|
||||
for(const bildId of bildIds){
|
||||
if(bildId == mainPicId){
|
||||
|
||||
if(window.largePicCache[bildId]){
|
||||
pics.push(window.largePicCache[bildId]);
|
||||
}else if(window.mediumPicCache[bildId]){
|
||||
pics.push(window.mediumPicCache[bildId]);
|
||||
if(this.props.fullscreenOpen) this.loadPic('large',bildId,newMainPic);
|
||||
}else if(window.smallPicCache[bildId]){
|
||||
pics.push(window.smallPicCache[bildId]);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}else if(window.tinyPicCache[bildId]){
|
||||
pics.push(bildId);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}else{
|
||||
pics.push(bildId);
|
||||
this.loadPic(this.props.fullscreenOpen ? 'large' : 'medium',bildId,newMainPic);
|
||||
}
|
||||
}else{
|
||||
if(window.tinyPicCache[bildId]){
|
||||
pics.push(window.tinyPicCache[bildId]);
|
||||
}else if(window.mediumPicCache[bildId]){
|
||||
pics.push(window.mediumPicCache[bildId]);
|
||||
this.loadPic('tiny',bildId,newMainPic);
|
||||
}else{
|
||||
pics.push(null);
|
||||
this.loadPic('tiny',bildId,pics.length-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('pics',pics);
|
||||
this.setState({ pics, mainPic: newMainPic });
|
||||
}else{
|
||||
if(this.state.pics.length > 0) this.setState({ pics:[], mainPic: newMainPic });
|
||||
}
|
||||
}
|
||||
|
||||
loadPic = (size,bildId,index) => {
|
||||
this.props.socket.emit('getPic', { bildId, size }, (res) => {
|
||||
if(res.success){
|
||||
const url = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
|
||||
if(size === 'medium') window.mediumPicCache[bildId] = url;
|
||||
if(size === 'small') window.smallPicCache[bildId] = url;
|
||||
if(size === 'tiny') window.tinyPicCache[bildId] = url;
|
||||
if(size === 'large') window.largePicCache[bildId] = url;
|
||||
const pics = this.state.pics;
|
||||
pics[index] = url
|
||||
this.setState({ pics });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleThumbnailClick = (clickedPic) => {
|
||||
// Find the original index of the clicked picture in the full pics array
|
||||
const originalIndex = this.state.pics.findIndex(pic => pic === clickedPic);
|
||||
if (originalIndex !== -1) {
|
||||
this.updatePics(originalIndex);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
{this.state.pics[this.state.mainPic] && (
|
||||
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="400"
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)'
|
||||
}
|
||||
}}
|
||||
image={this.state.pics[this.state.mainPic]}
|
||||
onClick={this.props.onOpenFullscreen}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
disableRipple
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
pointerEvents: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LoupeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-start', mt: 1,mb: 1 }}>
|
||||
{this.state.pics.filter(pic => pic !== null && pic !== this.state.pics[this.state.mainPic]).map((pic, filterIndex) => {
|
||||
// Find the original index in the full pics array
|
||||
const originalIndex = this.state.pics.findIndex(p => p === pic);
|
||||
return (
|
||||
<Box key={filterIndex} sx={{ position: 'relative' }}>
|
||||
<Badge
|
||||
badgeContent={originalIndex + 1}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
backgroundColor: 'rgba(119, 155, 191, 0.79)',
|
||||
color: 'white',
|
||||
fontSize: '0.7rem',
|
||||
minWidth: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
top: 4,
|
||||
right: 4,
|
||||
border: '2px solid rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: 'bold',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out'
|
||||
},
|
||||
'&:hover .MuiBadge-badge': {
|
||||
opacity: 1
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="80"
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
border: '2px solid transparent',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
border: '2px solid #1976d2',
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.2)'
|
||||
}
|
||||
}}
|
||||
image={pic}
|
||||
onClick={() => this.handleThumbnailClick(pic)}
|
||||
/>
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{/* Fullscreen Dialog */}
|
||||
<Dialog
|
||||
open={this.props.fullscreenOpen || false}
|
||||
onClose={this.props.onCloseFullscreen}
|
||||
maxWidth={false}
|
||||
fullScreen
|
||||
sx={{
|
||||
'& .MuiDialog-paper': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
sx={{
|
||||
p: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
height: '100vh',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Only close if clicking on the background (DialogContent itself)
|
||||
if (e.target === e.currentTarget) {
|
||||
this.props.onCloseFullscreen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<IconButton
|
||||
onClick={this.props.onCloseFullscreen}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
|
||||
{/* Main Image in Fullscreen */}
|
||||
{this.state.pics[this.state.mainPic] && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
width: '90vw',
|
||||
height: '80vh'
|
||||
}}
|
||||
image={this.state.pics[this.state.mainPic]}
|
||||
onClick={this.props.onCloseFullscreen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Stack in Fullscreen */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: '90%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Stack direction="row" spacing={2} sx={{ justifyContent: 'center', p: 3 }}>
|
||||
{this.state.pics.filter(pic => pic !== null && pic !== this.state.pics[this.state.mainPic]).map((pic, filterIndex) => {
|
||||
// Find the original index in the full pics array
|
||||
const originalIndex = this.state.pics.findIndex(p => p === pic);
|
||||
return (
|
||||
<Box key={filterIndex} sx={{ position: 'relative' }}>
|
||||
<Badge
|
||||
badgeContent={originalIndex + 1}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
backgroundColor: 'rgba(119, 155, 191, 0.79)',
|
||||
color: 'white',
|
||||
fontSize: '0.7rem',
|
||||
minWidth: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
top: 4,
|
||||
right: 4,
|
||||
border: '2px solid rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: 'bold',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out'
|
||||
},
|
||||
'&:hover .MuiBadge-badge': {
|
||||
opacity: 1
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="60"
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
border: '2px solid #1976d2',
|
||||
transform: 'scale(1.1)',
|
||||
boxShadow: '0 4px 8px rgba(25, 118, 210, 0.5)'
|
||||
}
|
||||
}}
|
||||
image={pic}
|
||||
onClick={() => this.handleThumbnailClick(pic)}
|
||||
/>
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Images;
|
||||
743
src/components/LoginComponent.js
Normal file
743
src/components/LoginComponent.js
Normal file
@@ -0,0 +1,743 @@
|
||||
import React, { lazy, Component, Suspense } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import { withRouter } from './withRouter.js';
|
||||
import GoogleLoginButton from './GoogleLoginButton.js';
|
||||
import CartSyncDialog from './CartSyncDialog.js';
|
||||
import { localAndArchiveServer, mergeCarts } from '../utils/cartUtils.js';
|
||||
import config from '../config.js';
|
||||
|
||||
// Lazy load GoogleAuthProvider
|
||||
const GoogleAuthProvider = lazy(() => import('../providers/GoogleAuthProvider.js'));
|
||||
|
||||
// Function to check if user is logged in
|
||||
export const isUserLoggedIn = () => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(storedUser);
|
||||
console.log('Parsed User:', parsedUser);
|
||||
return { isLoggedIn: true, user: parsedUser, isAdmin: !!parsedUser.admin };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error parsing user from sessionStorage:', error);
|
||||
sessionStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
console.log('isUserLoggedIn', false);
|
||||
return { isLoggedIn: false, user: null, isAdmin: false };
|
||||
};
|
||||
|
||||
// Hilfsfunktion zum Vergleich zweier Cart-Arrays
|
||||
function cartsAreIdentical(cartA, cartB) {
|
||||
console.log('Vergleiche Carts:', {cartA, cartB});
|
||||
if (!Array.isArray(cartA) || !Array.isArray(cartB)) {
|
||||
console.log('Mindestens eines der Carts ist kein Array');
|
||||
return false;
|
||||
}
|
||||
if (cartA.length !== cartB.length) {
|
||||
console.log('Unterschiedliche Längen:', cartA.length, cartB.length);
|
||||
return false;
|
||||
}
|
||||
const sortById = arr => [...arr].sort((a, b) => (a.id > b.id ? 1 : -1));
|
||||
const aSorted = sortById(cartA);
|
||||
const bSorted = sortById(cartB);
|
||||
for (let i = 0; i < aSorted.length; i++) {
|
||||
if (aSorted[i].id !== bSorted[i].id) {
|
||||
console.log('Unterschiedliche IDs:', aSorted[i].id, bSorted[i].id, aSorted[i], bSorted[i]);
|
||||
return false;
|
||||
}
|
||||
if (aSorted[i].quantity !== bSorted[i].quantity) {
|
||||
console.log('Unterschiedliche Mengen:', aSorted[i].id, aSorted[i].quantity, bSorted[i].quantity);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
console.log('Carts sind identisch');
|
||||
return true;
|
||||
}
|
||||
|
||||
export class LoginComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
open: false,
|
||||
tabValue: 0,
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
error: '',
|
||||
loading: false,
|
||||
success: '',
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
user: null,
|
||||
anchorEl: null,
|
||||
showGoogleAuth: false,
|
||||
cartSyncOpen: false,
|
||||
localCartSync: [],
|
||||
serverCartSync: [],
|
||||
pendingNavigate: null,
|
||||
privacyConfirmed: sessionStorage.getItem('privacyConfirmed') === 'true'
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Make the open function available globally
|
||||
window.openLoginDrawer = this.handleOpen;
|
||||
|
||||
// Check if user is logged in
|
||||
const { isLoggedIn: userIsLoggedIn, user: storedUser } = isUserLoggedIn();
|
||||
if (userIsLoggedIn) {
|
||||
this.setState({
|
||||
user: storedUser,
|
||||
isAdmin: !!storedUser.admin,
|
||||
isLoggedIn: true
|
||||
});
|
||||
}
|
||||
|
||||
if (this.props.open) {
|
||||
this.setState({ open: true });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.open !== prevProps.open) {
|
||||
this.setState({ open: this.props.open });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup function to remove global reference when component unmounts
|
||||
window.openLoginDrawer = undefined;
|
||||
}
|
||||
|
||||
resetForm = () => {
|
||||
this.setState({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
error: '',
|
||||
success: '',
|
||||
loading: false,
|
||||
showGoogleAuth: false // Reset Google auth state when form is reset
|
||||
});
|
||||
};
|
||||
|
||||
handleOpen = () => {
|
||||
this.setState({
|
||||
open: true,
|
||||
loading: false,
|
||||
privacyConfirmed: sessionStorage.getItem('privacyConfirmed') === 'true'
|
||||
});
|
||||
this.resetForm();
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({
|
||||
open: false,
|
||||
showGoogleAuth: false // Reset Google auth state when dialog closes
|
||||
});
|
||||
this.resetForm();
|
||||
};
|
||||
|
||||
handleTabChange = (event, newValue) => {
|
||||
this.setState({
|
||||
tabValue: newValue,
|
||||
error: '',
|
||||
success: ''
|
||||
});
|
||||
};
|
||||
|
||||
validateEmail = (email) => {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
};
|
||||
|
||||
handleLogin = () => {
|
||||
const { email, password } = this.state;
|
||||
const { socket, location, navigate } = this.props;
|
||||
|
||||
if (!email || !password) {
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call verifyUser socket endpoint
|
||||
if (!socket || !socket.connected) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('verifyUser', { email, password }, (response) => {
|
||||
console.log('LoginComponent: verifyUser', response);
|
||||
if (response.success) {
|
||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||
this.setState({
|
||||
user: response.user,
|
||||
isLoggedIn: true,
|
||||
isAdmin: !!response.user.admin
|
||||
});
|
||||
|
||||
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
const dispatchLoginEvent = () => {
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
navigate(redirectTo);
|
||||
}
|
||||
|
||||
try {
|
||||
const newCart = JSON.parse(response.user.cart);
|
||||
const localCartArr = window.cart ? Object.values(window.cart) : [];
|
||||
const serverCartArr = newCart ? Object.values(newCart) : [];
|
||||
|
||||
if (serverCartArr.length === 0) {
|
||||
if (socket && socket.connected) {
|
||||
socket.emit('updateCart', window.cart);
|
||||
}
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
||||
window.cart = serverCartArr;
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (cartsAreIdentical(localCartArr, serverCartArr)) {
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else {
|
||||
this.setState({
|
||||
cartSyncOpen: true,
|
||||
localCartSync: localCartArr,
|
||||
serverCartSync: serverCartArr,
|
||||
pendingNavigate: dispatchLoginEvent
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing cart:', response.user, error);
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Anmeldung fehlgeschlagen'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleRegister = () => {
|
||||
const { email, password, confirmPassword } = this.state;
|
||||
const { socket } = this.props;
|
||||
|
||||
if (!email || !password || !confirmPassword) {
|
||||
this.setState({ error: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
this.setState({ error: 'Passwörter stimmen nicht überein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
this.setState({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call createUser socket endpoint
|
||||
if (!socket || !socket.connected) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Verbindung zum Server verloren. Bitte versuchen Sie es erneut.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('createUser', { email, password }, (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: 'Registrierung erfolgreich. Sie können sich jetzt anmelden.',
|
||||
tabValue: 0 // Switch to login tab
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Registrierung fehlgeschlagen'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleUserMenuClick = (event) => {
|
||||
this.setState({ anchorEl: event.currentTarget });
|
||||
};
|
||||
|
||||
handleUserMenuClose = () => {
|
||||
this.setState({ anchorEl: null });
|
||||
};
|
||||
|
||||
handleLogout = () => {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// If socket is not connected, just clear local storage
|
||||
sessionStorage.removeItem('user');
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
window.dispatchEvent(new CustomEvent('userLoggedOut'));
|
||||
this.setState({
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
isAdmin: false,
|
||||
anchorEl: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit('logout', (response) => {
|
||||
if(response.success){
|
||||
sessionStorage.removeItem('user');
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
this.props.navigate('/');
|
||||
this.setState({
|
||||
user: null,
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
anchorEl: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleForgotPassword = () => {
|
||||
const { email } = this.state;
|
||||
const { socket } = this.props;
|
||||
|
||||
if (!email) {
|
||||
this.setState({ error: 'Bitte geben Sie Ihre E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateEmail(email)) {
|
||||
this.setState({ error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: '' });
|
||||
|
||||
// Call resetPassword socket endpoint
|
||||
socket.emit('resetPassword', {
|
||||
email,
|
||||
domain: window.location.origin
|
||||
}, (response) => {
|
||||
console.log('Reset Password Response:', response);
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
success: 'Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.'
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: response.message || 'Fehler beim Senden der E-Mail'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Google login functionality
|
||||
handleGoogleLoginSuccess = (credentialResponse) => {
|
||||
const { socket, location, navigate } = this.props;
|
||||
this.setState({ loading: true, error: '' });
|
||||
console.log('beforeG',credentialResponse)
|
||||
|
||||
|
||||
|
||||
socket.emit('verifyGoogleUser', { credential: credentialResponse.credential }, (response) => {
|
||||
console.log('google respo',response);
|
||||
if (response.success) {
|
||||
sessionStorage.setItem('user', JSON.stringify(response.user));
|
||||
this.setState({
|
||||
isLoggedIn: true,
|
||||
isAdmin: !!response.user.admin,
|
||||
user: response.user
|
||||
});
|
||||
|
||||
const redirectTo = location && location.hash ? `/profile${location.hash}` : '/profile';
|
||||
const dispatchLoginEvent = () => {
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn'));
|
||||
navigate(redirectTo);
|
||||
};
|
||||
|
||||
try {
|
||||
const newCart = JSON.parse(response.user.cart);
|
||||
const localCartArr = window.cart ? Object.values(window.cart) : [];
|
||||
const serverCartArr = newCart ? Object.values(newCart) : [];
|
||||
|
||||
if (serverCartArr.length === 0) {
|
||||
socket.emit('updateCart', window.cart);
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (localCartArr.length === 0 && serverCartArr.length > 0) {
|
||||
window.cart = serverCartArr;
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else if (cartsAreIdentical(localCartArr, serverCartArr)) {
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
} else {
|
||||
this.setState({
|
||||
cartSyncOpen: true,
|
||||
localCartSync: localCartArr,
|
||||
serverCartSync: serverCartArr,
|
||||
pendingNavigate: dispatchLoginEvent
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing cart:', response.user, error);
|
||||
this.handleClose();
|
||||
dispatchLoginEvent();
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false // Reset Google auth state on failed login
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleGoogleLoginError = (error) => {
|
||||
console.error('Google Login Error:', error);
|
||||
this.setState({
|
||||
error: 'Google-Anmeldung fehlgeschlagen',
|
||||
showGoogleAuth: false, // Reset Google auth state on error
|
||||
loading: false
|
||||
});
|
||||
};
|
||||
|
||||
handleCartSyncConfirm = async (option) => {
|
||||
const { localCartSync, serverCartSync, pendingNavigate } = this.state;
|
||||
switch (option) {
|
||||
case 'useLocalArchive':
|
||||
localAndArchiveServer(localCartSync, serverCartSync);
|
||||
break;
|
||||
case 'deleteServer':
|
||||
this.props.socket.emit('updateCart', window.cart)
|
||||
break;
|
||||
case 'useServer':
|
||||
window.cart = serverCartSync;
|
||||
break;
|
||||
case 'merge':
|
||||
default: {
|
||||
const merged = mergeCarts(localCartSync, serverCartSync);
|
||||
console.log('MERGED CART RESULT:', merged);
|
||||
window.cart = merged;
|
||||
break;
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('cart'));
|
||||
this.setState({ cartSyncOpen: false, localCartSync: [], serverCartSync: [], pendingNavigate: null });
|
||||
this.handleClose();
|
||||
if (pendingNavigate) pendingNavigate();
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
open,
|
||||
tabValue,
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
error,
|
||||
loading,
|
||||
success,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
anchorEl,
|
||||
showGoogleAuth,
|
||||
cartSyncOpen,
|
||||
localCartSync,
|
||||
serverCartSync,
|
||||
privacyConfirmed
|
||||
} = this.state;
|
||||
|
||||
const { open: openProp, handleClose: handleCloseProp } = this.props;
|
||||
const isExternallyControlled = openProp !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isExternallyControlled && (
|
||||
isLoggedIn ? (
|
||||
<>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={this.handleUserMenuClick}
|
||||
startIcon={<PersonIcon />}
|
||||
color={isAdmin ? 'secondary' : 'inherit'}
|
||||
sx={{ my: 1, mx: 1.5 }}
|
||||
>
|
||||
Profil
|
||||
</Button>
|
||||
<Menu
|
||||
disableScrollLock={true}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={this.handleUserMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<MenuItem component={Link} to="/profile" onClick={this.handleUserMenuClose}>Profil</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#cart" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellabschluss</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#orders" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Bestellungen</MenuItem>
|
||||
<MenuItem component={Link} to="/profile#settings" onClick={this.handleUserMenuClose} sx={{ pl: 4 }}>Einstellungen</MenuItem>
|
||||
<Divider />
|
||||
{isAdmin ? <MenuItem component={Link} to="/admin" onClick={this.handleUserMenuClose}>Admin Dashboard</MenuItem> : null}
|
||||
{isAdmin ? <MenuItem component={Link} to="/admin/users" onClick={this.handleUserMenuClose}>Admin Users</MenuItem> : null}
|
||||
<MenuItem onClick={this.handleLogout}>Abmelden</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={this.handleOpen}
|
||||
sx={{ my: 1, mx: 1.5 }}
|
||||
>
|
||||
Anmelden
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleCloseProp || this.handleClose}
|
||||
disableScrollLock
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
>
|
||||
<DialogTitle sx={{ bgcolor: 'white', pb: 0 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h6" color="#2e7d32" fontWeight="bold">
|
||||
{tabValue === 0 ? 'Anmelden' : 'Registrieren'}
|
||||
</Typography>
|
||||
<IconButton edge="end" onClick={this.handleClose} aria-label="close">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={this.handleTabChange}
|
||||
variant="fullWidth"
|
||||
sx={{ mb: 2 }}
|
||||
TabIndicatorProps={{
|
||||
style: { backgroundColor: '#2e7d32' }
|
||||
}}
|
||||
textColor="inherit"
|
||||
>
|
||||
<Tab
|
||||
label="ANMELDEN"
|
||||
sx={{
|
||||
color: tabValue === 0 ? '#2e7d32' : 'inherit',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="REGISTRIEREN"
|
||||
sx={{
|
||||
color: tabValue === 1 ? '#2e7d32' : 'inherit',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
|
||||
{/* Google Sign In Button */}
|
||||
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', mb: 2 }}>
|
||||
{!privacyConfirmed && (
|
||||
<Typography variant="caption" sx={{ mb: 1, textAlign: 'center' }}>
|
||||
Mit dem Click auf "Mit Google anmelden" akzeptiere ich die <Link to="/datenschutz" style={{ color: '#4285F4' }}>Datenschutzbestimmungen</Link>
|
||||
</Typography>
|
||||
)}
|
||||
{!showGoogleAuth && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonIcon />}
|
||||
onClick={() => {
|
||||
sessionStorage.setItem('privacyConfirmed', 'true');
|
||||
this.setState({ showGoogleAuth: true, privacyConfirmed: true });
|
||||
}}
|
||||
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
|
||||
>
|
||||
Mit Google anmelden
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showGoogleAuth && (
|
||||
<Suspense fallback={
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonIcon />}
|
||||
sx={{ width: '100%', backgroundColor: '#4285F4', color: 'white' }}
|
||||
>
|
||||
Mit Google anmelden
|
||||
</Button>
|
||||
}>
|
||||
<GoogleAuthProvider clientId={config.googleClientId}>
|
||||
<GoogleLoginButton
|
||||
onSuccess={this.handleGoogleLoginSuccess}
|
||||
onError={this.handleGoogleLoginError}
|
||||
text="Mit Google anmelden"
|
||||
style={{ width: '100%', backgroundColor: '#4285F4' }}
|
||||
autoInitiate={true}
|
||||
/>
|
||||
</GoogleAuthProvider>
|
||||
</Suspense>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
|
||||
{/* OR Divider */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', my: 2 }}>
|
||||
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
|
||||
<Typography variant="body2" sx={{ px: 2, color: '#757575' }}>ODER</Typography>
|
||||
<Box sx={{ flex: 1, height: '1px', backgroundColor: '#e0e0e0' }} />
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
|
||||
|
||||
|
||||
<Box sx={{ py: 1 }}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={email}
|
||||
onChange={(e) => this.setState({ email: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={password}
|
||||
onChange={(e) => this.setState({ password: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{tabValue === 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1, mb: 1 }}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={this.handleForgotPassword}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
textTransform: 'none',
|
||||
'&:hover': { backgroundColor: 'transparent', textDecoration: 'underline' }
|
||||
}}
|
||||
>
|
||||
Passwort vergessen?
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tabValue === 1 && (
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Passwort bestätigen"
|
||||
type="password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => this.setState({ confirmPassword: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" mt={2}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={tabValue === 0 ? this.handleLogin : this.handleRegister}
|
||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||
>
|
||||
{tabValue === 0 ? 'ANMELDEN' : 'REGISTRIEREN'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CartSyncDialog
|
||||
open={cartSyncOpen}
|
||||
localCart={localCartSync}
|
||||
serverCart={serverCartSync}
|
||||
onClose={() => {
|
||||
const { pendingNavigate } = this.state;
|
||||
this.setState({ cartSyncOpen: false, pendingNavigate: null });
|
||||
this.handleClose();
|
||||
if (pendingNavigate) pendingNavigate();
|
||||
}}
|
||||
onConfirm={this.handleCartSyncConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(LoginComponent);
|
||||
365
src/components/Product.js
Normal file
365
src/components/Product.js
Normal file
@@ -0,0 +1,365 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddToCartButton from './AddToCartButton.js';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
|
||||
class Product extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._isMounted = false;
|
||||
|
||||
if (!window.smallPicCache) {
|
||||
window.smallPicCache = {};
|
||||
}
|
||||
|
||||
if(this.props.pictureList && this.props.pictureList.length > 0 && this.props.pictureList.split(',').length > 0) {
|
||||
const bildId = this.props.pictureList.split(',')[0];
|
||||
if(window.smallPicCache[bildId]){
|
||||
this.state = {image:window.smallPicCache[bildId],loading:false, error: false}
|
||||
}else{
|
||||
this.state = {image: null, loading: true, error: false};
|
||||
this.props.socket.emit('getPic', { bildId, size:'small' }, (res) => {
|
||||
if(res.success){
|
||||
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/jpeg' }));
|
||||
if (this._isMounted) {
|
||||
this.setState({image: window.smallPicCache[bildId], loading: false});
|
||||
} else {
|
||||
this.state.image = window.smallPicCache[bildId];
|
||||
this.state.loading = false;
|
||||
}
|
||||
}else{
|
||||
console.log('Fehler beim Laden des Bildes:', res);
|
||||
if (this._isMounted) {
|
||||
this.setState({error: true, loading: false});
|
||||
} else {
|
||||
this.state.error = true;
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}else{
|
||||
this.state = {image: null, loading: false, error: false};
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
}
|
||||
|
||||
handleQuantityChange = (quantity) => {
|
||||
console.log(`Product: ${this.props.name}, Quantity: ${quantity}`);
|
||||
// In a real app, this would update a cart state in a parent component or Redux store
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
id, name, price, available, manufacturer, seoName,
|
||||
currency, vat, massMenge, massEinheit, thc,
|
||||
floweringWeeks,incoming, neu, weight, versandklasse, availableSupplier
|
||||
} = this.props;
|
||||
|
||||
const isNew = neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
const showThcBadge = thc > 0;
|
||||
let thcBadgeColor = '#4caf50'; // Green default
|
||||
if (thc > 30) {
|
||||
thcBadgeColor = '#f44336'; // Red for > 30
|
||||
} else if (thc > 25) {
|
||||
thcBadgeColor = '#ffeb3b'; // Yellow for > 25
|
||||
}
|
||||
const showFloweringWeeksBadge = floweringWeeks > 0;
|
||||
let floweringWeeksBadgeColor = '#4caf50'; // Green default
|
||||
if (floweringWeeks > 12) {
|
||||
floweringWeeksBadgeColor = '#f44336'; // Red for > 12
|
||||
} else if (floweringWeeks > 8) {
|
||||
floweringWeeksBadgeColor = '#ffeb3b'; // Yellow for > 8
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
width: { xs: '100%', sm: 'auto' }
|
||||
}}>
|
||||
{isNew && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-15px',
|
||||
right: '-15px',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
zIndex: 999,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
{/* Background star - slightly larger and rotated */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="56"
|
||||
height="56"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-3px',
|
||||
left: '-3px',
|
||||
transform: 'rotate(20deg)'
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#20403a"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Middle star - medium size with different rotation */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="53"
|
||||
height="53"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-1.5px',
|
||||
left: '-1.5px',
|
||||
transform: 'rotate(-25deg)'
|
||||
}}
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#40736b"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Foreground star - main star with text */}
|
||||
<svg
|
||||
viewBox="0 0 60 60"
|
||||
width="50"
|
||||
height="50"
|
||||
>
|
||||
<polygon
|
||||
points="30,0 38,20 60,22 43,37 48,60 30,48 12,60 17,37 0,22 22,20"
|
||||
fill="#609688"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Text as a separate element to position it at the top */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '45%',
|
||||
left: '45%',
|
||||
transform: 'translate(-50%, -50%) rotate(-10deg)',
|
||||
color: 'white',
|
||||
fontWeight: '900',
|
||||
fontSize: '16px',
|
||||
textShadow: '0px 1px 2px rgba(0,0,0,0.5)',
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
NEU
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
sx={{
|
||||
width: { xs: 'calc(100vw - 48px)', sm: '250px' },
|
||||
minWidth: { xs: 'calc(100vw - 48px)', sm: '250px' },
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '8px',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0px 10px 20px rgba(0,0,0,0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showThcBadge && (
|
||||
<div aria-label={`THC Anteil: ${thc}%`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
backgroundColor: thcBadgeColor,
|
||||
color: thc > 25 && thc <= 30 ? '#000000' : '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
padding: '2px 0',
|
||||
width: '80px',
|
||||
textAlign: 'center',
|
||||
zIndex: 999,
|
||||
fontSize: '9px',
|
||||
boxShadow: '0px 2px 4px rgba(0,0,0,0.2)',
|
||||
transform: 'rotate(-45deg) translateX(-40px) translateY(15px)',
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
THC {thc}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFloweringWeeksBadge && (
|
||||
<div aria-label={`Flowering Weeks: ${floweringWeeks}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
backgroundColor: floweringWeeksBadgeColor,
|
||||
color: floweringWeeks > 8 && floweringWeeks <= 12 ? '#000000' : '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
padding: '1px 0',
|
||||
width: '100px',
|
||||
textAlign: 'center',
|
||||
zIndex: 999,
|
||||
fontSize: '9px',
|
||||
boxShadow: '0px 2px 4px rgba(0,0,0,0.2)',
|
||||
transform: 'rotate(-45deg) translateX(-50px) translateY(32px)',
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
{floweringWeeks} Wochen
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/Artikel/${seoName}`}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit'
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
position: 'relative',
|
||||
height: { xs: '240px', sm: '180px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px'
|
||||
}}>
|
||||
{this.state.loading ? (
|
||||
<CircularProgress sx={{ color: '#90ffc0' }} />
|
||||
|
||||
) : this.state.image === null ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
||||
image="/assets/images/nopicture.jpg"
|
||||
alt={name}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height={ window.innerWidth < 600 ? "240" : "180" }
|
||||
image={this.state.image}
|
||||
alt={name}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<CardContent sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'&.MuiCardContent-root:last-child': {
|
||||
paddingBottom: 0
|
||||
}
|
||||
}}>
|
||||
<Typography
|
||||
gutterBottom
|
||||
variant="h6"
|
||||
component="h2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minHeight: '3.4em'
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" style={{minHeight:'1.5em'}}>
|
||||
{manufacturer || ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<div style={{padding:'0px',margin:'0px',minHeight:'3.8em'}}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="primary"
|
||||
sx={{ mt: 'auto', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
|
||||
>
|
||||
<span>{new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price)}</span>
|
||||
<small style={{ color: '#77aa77', fontSize: '0.6em' }}>(incl. {vat}% USt.,*)</small>
|
||||
|
||||
|
||||
|
||||
</Typography>
|
||||
{massMenge != 1 && massEinheit && (<Typography variant="body2" color="text.secondary" sx={{ m: 0,p: 0 }}>
|
||||
({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(price/massMenge)}/{massEinheit})
|
||||
</Typography> )}
|
||||
</div>
|
||||
{/*incoming*/}
|
||||
</CardContent>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2, pt: 0, display: 'flex', alignItems: 'center' }}>
|
||||
<IconButton
|
||||
component={Link}
|
||||
to={`/Artikel/${seoName}`}
|
||||
size="small"
|
||||
sx={{ mr: 1, color: 'text.secondary' }}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</IconButton>
|
||||
<AddToCartButton cartButton={true} availableSupplier={availableSupplier} available={available} incoming={incoming} seoName={seoName} pictureList={this.props.pictureList} id={id} price={price} vat={vat} weight={weight} name={name} versandklasse={versandklasse}/>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Product;
|
||||
572
src/components/ProductDetailPage.js
Normal file
572
src/components/ProductDetailPage.js
Normal file
@@ -0,0 +1,572 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Typography, CardMedia, Stack, Chip } from "@mui/material";
|
||||
import { Link } from "react-router-dom";
|
||||
import parse from "html-react-parser";
|
||||
import AddToCartButton from "./AddToCartButton.js";
|
||||
import Images from "./Images.js";
|
||||
|
||||
// Utility function to clean product names by removing trailing number in parentheses
|
||||
const cleanProductName = (name) => {
|
||||
if (!name) return "";
|
||||
// Remove patterns like " (1)", " (3)", " (10)" at the end of the string
|
||||
return name.replace(/\s*\(\d+\)\s*$/, "").trim();
|
||||
};
|
||||
|
||||
// Product detail page with image loading
|
||||
class ProductDetailPage extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
if (
|
||||
window.productDetailCache &&
|
||||
window.productDetailCache[this.props.seoName]
|
||||
) {
|
||||
this.state = {
|
||||
product: window.productDetailCache[this.props.seoName],
|
||||
loading: false,
|
||||
error: null,
|
||||
attributeImages: {},
|
||||
attributes: [],
|
||||
isSteckling: false,
|
||||
imageDialogOpen: false,
|
||||
};
|
||||
} else {
|
||||
this.state = {
|
||||
product: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
attributeImages: {},
|
||||
attributes: [],
|
||||
isSteckling: false,
|
||||
imageDialogOpen: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadProductData();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.seoName !== this.props.seoName)
|
||||
this.setState(
|
||||
{ product: null, loading: true, error: null, imageDialogOpen: false },
|
||||
this.loadProductData
|
||||
);
|
||||
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected && this.state.loading) {
|
||||
// Socket just connected and we're still loading, retry loading data
|
||||
this.loadProductData();
|
||||
}
|
||||
}
|
||||
|
||||
loadProductData = () => {
|
||||
if (!this.props.socket || !this.props.socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to load product data");
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.socket.emit(
|
||||
"getProductView",
|
||||
{ seoName: this.props.seoName },
|
||||
(res) => {
|
||||
if (res.success) {
|
||||
res.product.seoName = this.props.seoName;
|
||||
this.setState({
|
||||
product: res.product,
|
||||
loading: false,
|
||||
error: null,
|
||||
imageDialogOpen: false,
|
||||
attributes: res.attributes
|
||||
});
|
||||
console.log("getProductView", res);
|
||||
|
||||
// Initialize window-level attribute image cache if it doesn't exist
|
||||
if (!window.attributeImageCache) {
|
||||
window.attributeImageCache = {};
|
||||
}
|
||||
|
||||
if (res.attributes && res.attributes.length > 0) {
|
||||
const attributeImages = {};
|
||||
|
||||
for (const attribute of res.attributes) {
|
||||
const cacheKey = attribute.kMerkmalWert;
|
||||
|
||||
if (attribute.cName == "Anzahl")
|
||||
this.setState({ isSteckling: true });
|
||||
|
||||
// Check if we have a cached result (either URL or negative result)
|
||||
if (window.attributeImageCache[cacheKey]) {
|
||||
const cached = window.attributeImageCache[cacheKey];
|
||||
if (cached.url) {
|
||||
// Use cached URL
|
||||
attributeImages[cacheKey] = cached.url;
|
||||
}
|
||||
} else {
|
||||
// Not in cache, fetch from server
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
this.props.socket.emit(
|
||||
"getAttributePicture",
|
||||
{ id: cacheKey },
|
||||
(res) => {
|
||||
console.log("getAttributePicture", res);
|
||||
if (res.success && !res.noPicture) {
|
||||
const blob = new Blob([res.imageBuffer], {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Cache the successful URL
|
||||
window.attributeImageCache[cacheKey] = {
|
||||
url: url,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Update state and force re-render
|
||||
this.setState(prevState => ({
|
||||
attributeImages: {
|
||||
...prevState.attributeImages,
|
||||
[cacheKey]: url
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
// Cache negative result to avoid future requests
|
||||
// This handles both failure cases and success with noPicture: true
|
||||
window.attributeImageCache[cacheKey] = {
|
||||
noImage: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial state with cached images
|
||||
if (Object.keys(attributeImages).length > 0) {
|
||||
this.setState({ attributeImages });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"Error loading product:",
|
||||
res.error || "Unknown error",
|
||||
res
|
||||
);
|
||||
this.setState({
|
||||
product: null,
|
||||
loading: false,
|
||||
error: "Error loading product",
|
||||
imageDialogOpen: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleOpenDialog = () => {
|
||||
this.setState({ imageDialogOpen: true });
|
||||
};
|
||||
|
||||
handleCloseDialog = () => {
|
||||
this.setState({ imageDialogOpen: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { product, loading, error, attributeImages, isSteckling, attributes } =
|
||||
this.state;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Produkt wird geladen...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ p: 4, textAlign: "center" }}>
|
||||
<Typography variant="h5" gutterBottom color="error">
|
||||
Fehler
|
||||
</Typography>
|
||||
<Typography>{error}</Typography>
|
||||
<Link to="/" style={{ textDecoration: "none" }}>
|
||||
<Typography color="primary" sx={{ mt: 2 }}>
|
||||
Zurück zur Startseite
|
||||
</Typography>
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<Box sx={{ p: 4, textAlign: "center" }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Produkt nicht gefunden
|
||||
</Typography>
|
||||
<Typography>
|
||||
Das gesuchte Produkt existiert nicht oder wurde entfernt.
|
||||
</Typography>
|
||||
<Link to="/" style={{ textDecoration: "none" }}>
|
||||
<Typography color="primary" sx={{ mt: 2 }}>
|
||||
Zurück zur Startseite
|
||||
</Typography>
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// Format price with tax
|
||||
const priceWithTax = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(product.price);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: { xs: 2, md: 2 },
|
||||
pb: { xs: 4, md: 8 },
|
||||
maxWidth: "1400px",
|
||||
mx: "auto",
|
||||
}}
|
||||
>
|
||||
{/* Breadcrumbs */}
|
||||
<Box
|
||||
sx={{
|
||||
mb: 2,
|
||||
position: ["-webkit-sticky", "sticky"], // Provide both prefixed and standard
|
||||
top: {
|
||||
xs: "80px",
|
||||
sm: "80px",
|
||||
md: "80px",
|
||||
lg: "80px",
|
||||
} /* Offset to sit below the header 120 mith menu for md and lg*/,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
zIndex: (theme) =>
|
||||
theme.zIndex.appBar - 1 /* Just below the AppBar */,
|
||||
py: 0,
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
ml: { xs: 0, md: 0 },
|
||||
display: "inline-flex",
|
||||
px: 0,
|
||||
py: 1,
|
||||
backgroundColor: "#2e7d32", //primary dark green
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => this.props.navigate(-1)}
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
textDecoration: "none",
|
||||
color: "#fff",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", md: "row" },
|
||||
gap: 4,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: { xs: "100%", sm: "555px" },
|
||||
maxWidth: "100%",
|
||||
minHeight: "400px",
|
||||
background: "#f8f8f8",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{!product.pictureList && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="400"
|
||||
image="/assets/images/nopicture.jpg"
|
||||
alt={product.name}
|
||||
sx={{ objectFit: "cover" }}
|
||||
/>
|
||||
)}
|
||||
{product.pictureList && (
|
||||
<Images
|
||||
socket={this.props.socket}
|
||||
pictureList={product.pictureList}
|
||||
fullscreenOpen={this.state.imageDialogOpen}
|
||||
onOpenFullscreen={this.handleOpenDialog}
|
||||
onCloseFullscreen={this.handleCloseDialog}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Product Details */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: "1 1 60%",
|
||||
p: { xs: 2, md: 4 },
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Product identifiers */}
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Artikelnummer: {product.articleNumber} {product.gtin ? ` | GTIN: ${product.gtin}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Product title */}
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 600, color: "#333" }}
|
||||
>
|
||||
{cleanProductName(product.name)}
|
||||
</Typography>
|
||||
|
||||
{/* Manufacturer if available */}
|
||||
{product.manufacturer && (
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontStyle: "italic" }}>
|
||||
Hersteller: {product.manufacturer}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Attribute images and chips */}
|
||||
{(attributes.some(attr => attributeImages[attr.kMerkmalWert]) || attributes.some(attr => !attributeImages[attr.kMerkmalWert])) && (
|
||||
<Stack direction="row" spacing={2} sx={{ flexWrap: "wrap", gap: 1, mb: 2 }}>
|
||||
{attributes
|
||||
.filter(attribute => attributeImages[attribute.kMerkmalWert])
|
||||
.map((attribute) => {
|
||||
const key = attribute.kMerkmalWert;
|
||||
return (
|
||||
<Box key={key} sx={{ mb: 1 }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={attributeImages[key]}
|
||||
alt={`Attribute ${key}`}
|
||||
sx={{
|
||||
maxWidth: "100px",
|
||||
maxHeight: "100px",
|
||||
objectFit: "contain",
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{attributes
|
||||
.filter(attribute => !attributeImages[attribute.kMerkmalWert])
|
||||
.map((attribute) => (
|
||||
<Chip
|
||||
key={attribute.kMerkmalWert}
|
||||
label={attribute.cWert}
|
||||
disabled
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Weight */}
|
||||
{product.weight > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Gewicht: {product.weight.toFixed(1).replace(".", ",")} kg
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Price and availability section */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: "auto",
|
||||
p: 3,
|
||||
background: "#f9f9f9",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
justifyContent: "space-between",
|
||||
alignItems: { xs: "flex-start", sm: "flex-start" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h4"
|
||||
color="primary"
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{priceWithTax}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
inkl. {product.vat}% MwSt.
|
||||
</Typography>
|
||||
{product.versandklasse &&
|
||||
product.versandklasse != "standard" &&
|
||||
product.versandklasse != "kostenlos" && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{product.versandklasse}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
gap: 2,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{isSteckling && product.available == 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<AddToCartButton
|
||||
steckling={true}
|
||||
cartButton={true}
|
||||
seoName={product.seoName}
|
||||
pictureList={product.pictureList}
|
||||
available={product.available}
|
||||
id={product.id + "steckling"}
|
||||
price={0}
|
||||
vat={product.vat}
|
||||
weight={product.weight}
|
||||
availableSupplier={product.availableSupplier}
|
||||
name={cleanProductName(product.name) + " Stecklingsvorbestellung 1 Stück"}
|
||||
versandklasse={"nur Abholung"}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
mt: 1
|
||||
}}
|
||||
>
|
||||
Abholpreis: 19,90 € pro Steckling.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<AddToCartButton
|
||||
cartButton={true}
|
||||
seoName={product.seoName}
|
||||
pictureList={product.pictureList}
|
||||
available={product.available}
|
||||
id={product.id}
|
||||
availableSupplier={product.availableSupplier}
|
||||
price={product.price}
|
||||
vat={product.vat}
|
||||
weight={product.weight}
|
||||
name={cleanProductName(product.name)}
|
||||
versandklasse={product.versandklasse}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
textAlign: "center",
|
||||
mt: 1
|
||||
}}
|
||||
>
|
||||
{product.id.toString().endsWith("steckling") ? "Lieferzeit: 14 Tage" :
|
||||
product.available == 1 ? "Lieferzeit: 2-3 Tage" :
|
||||
product.availableSupplier == 1 ? "Lieferzeit: 7-9 Tage" : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Product full description */}
|
||||
{product.description && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 4,
|
||||
p: 4,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
lineHeight: 1.7,
|
||||
"& p": { mt: 0, mb: 2 },
|
||||
"& strong": { fontWeight: 600 },
|
||||
}}
|
||||
>
|
||||
{parse(product.description)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductDetailPage;
|
||||
19
src/components/ProductDetailWithSocket.js
Normal file
19
src/components/ProductDetailWithSocket.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import SocketContext from '../contexts/SocketContext.js';
|
||||
import ProductDetailPage from './ProductDetailPage.js';
|
||||
|
||||
// Wrapper component for individual product detail page with socket
|
||||
const ProductDetailWithSocket = () => {
|
||||
const { seoName } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<SocketContext.Consumer>
|
||||
{socket => <ProductDetailPage seoName={seoName} navigate={navigate} location={location} socket={socket} />}
|
||||
</SocketContext.Consumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailWithSocket;
|
||||
256
src/components/ProductFilters.js
Normal file
256
src/components/ProductFilters.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { Component } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Filter from './Filter.js';
|
||||
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// HOC to provide router props to class components
|
||||
const withRouter = (ClassComponent) => {
|
||||
return (props) => {
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return <ClassComponent
|
||||
{...props}
|
||||
params={params}
|
||||
searchParams={searchParams}
|
||||
navigate={navigate}
|
||||
location={location}
|
||||
/>;
|
||||
};
|
||||
};
|
||||
|
||||
class ProductFilters extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const uniqueManufacturerArray = this._getUniqueManufacturers(this.props.products);
|
||||
const attributeGroups = this._getAttributeGroups(this.props.attributes);
|
||||
const availabilityValues = this._getAvailabilityValues(this.props.products);
|
||||
|
||||
this.state = {
|
||||
availabilityValues,
|
||||
uniqueManufacturerArray,
|
||||
attributeGroups
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Measure the available space dynamically
|
||||
this.adjustPaperHeight();
|
||||
// Add event listener for window resize
|
||||
window.addEventListener('resize', this.adjustPaperHeight);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Remove event listener when component unmounts
|
||||
window.removeEventListener('resize', this.adjustPaperHeight);
|
||||
}
|
||||
|
||||
adjustPaperHeight = () => {
|
||||
// Skip height adjustment on xs screens
|
||||
if (window.innerWidth < 600) return;
|
||||
|
||||
// Get reference to our paper element
|
||||
const paperEl = document.getElementById('filters-paper');
|
||||
if (!paperEl) return;
|
||||
|
||||
// Get viewport height
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Get the offset top position of our paper element
|
||||
const paperTop = paperEl.getBoundingClientRect().top;
|
||||
|
||||
// Estimate footer height (adjust as needed)
|
||||
const footerHeight = 80; // Reduce from 150px
|
||||
|
||||
// Calculate available space and set height
|
||||
const availableHeight = viewportHeight - paperTop - footerHeight;
|
||||
// Add a smaller buffer margin to prevent scrolling but get closer to footer
|
||||
const heightWithBuffer = availableHeight - 20; // Reduce buffer from 50px to 20px
|
||||
paperEl.style.minHeight = `${heightWithBuffer}px`;
|
||||
}
|
||||
|
||||
_getUniqueManufacturers = (products) => {
|
||||
const manufacturers = {};
|
||||
|
||||
for (const product of products)
|
||||
if (!manufacturers[product.manufacturerId])
|
||||
manufacturers[product.manufacturerId] = product.manufacturer;
|
||||
|
||||
const uniqueManufacturerArray = Object.entries(manufacturers)
|
||||
.filter(([_id, name]) => name !== null) // Filter out null names
|
||||
.map(([id, name]) => ({
|
||||
id: parseInt(id),
|
||||
name: name
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return uniqueManufacturerArray;
|
||||
}
|
||||
|
||||
_getAvailabilityValues = (products) => {
|
||||
const filters = [{id:1,name:'auf Lager'}];
|
||||
|
||||
for(const product of products){
|
||||
if(isNew(product.neu)){
|
||||
if(!filters.find(filter => filter.id == 2)) filters.push({id:2,name:'Neu'});
|
||||
}
|
||||
if(!product.available && product.incomingDate){
|
||||
if(!filters.find(filter => filter.id == 3)) filters.push({id:3,name:'Bald verfügbar'});
|
||||
}
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
_getAttributeGroups = (attributes) => {
|
||||
const attributeGroups = {};
|
||||
if(attributes) for(const attribute of attributes) {
|
||||
if(!attributeGroups[attribute.cName]) attributeGroups[attribute.cName] = {name:attribute.cName, values:{}};
|
||||
attributeGroups[attribute.cName].values[attribute.kMerkmalWert] = {id:attribute.kMerkmalWert, name:attribute.cWert};
|
||||
}
|
||||
return attributeGroups;
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
if(nextProps.products !== this.props.products) {
|
||||
const uniqueManufacturerArray = this._getUniqueManufacturers(nextProps.products);
|
||||
const availabilityValues = this._getAvailabilityValues(nextProps.products);
|
||||
this.setState({uniqueManufacturerArray, availabilityValues});
|
||||
}
|
||||
if(nextProps.attributes !== this.props.attributes) {
|
||||
const attributeGroups = this._getAttributeGroups(nextProps.attributes);
|
||||
this.setState({attributeGroups});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
generateAttributeFilters = () => {
|
||||
const filters = [];
|
||||
const sortedAttributeGroups = Object.values(this.state.attributeGroups)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for(const attributeGroup of sortedAttributeGroups) {
|
||||
const filter = (
|
||||
<Filter
|
||||
key={`attr-filter-${attributeGroup.name}`}
|
||||
title={attributeGroup.name}
|
||||
options={Object.values(attributeGroup.values)}
|
||||
filterType="attribute"
|
||||
products={this.props.products}
|
||||
filteredProducts={this.props.filteredProducts}
|
||||
attributes={this.props.attributes}
|
||||
onFilterChange={(msg)=>{
|
||||
if(msg.value) {
|
||||
setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');
|
||||
} else {
|
||||
removeSessionSetting(`filter_${msg.type}_${msg.name}`);
|
||||
}
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
filters.push(filter);
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Paper
|
||||
id="filters-paper"
|
||||
elevation={1}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
|
||||
{this.props.dataType == 'category' && (
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h1"
|
||||
sx={{
|
||||
mb: 4,
|
||||
fontFamily: 'SwashingtonCP',
|
||||
color: 'primary.main'
|
||||
}}
|
||||
>
|
||||
{this.props.dataParam}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
|
||||
{this.props.products.length > 0 && (
|
||||
<><Filter
|
||||
title="Verfügbarkeit"
|
||||
options={this.state.availabilityValues}
|
||||
searchParams={this.props.searchParams}
|
||||
products={this.props.products}
|
||||
filteredProducts={this.props.filteredProducts}
|
||||
attributes={this.props.attributes}
|
||||
filterType="availability"
|
||||
onFilterChange={(msg)=>{
|
||||
|
||||
if(msg.resetAll) {
|
||||
sessionStorage.removeItem('filter_availability');
|
||||
clearAllSessionSettings();
|
||||
this.props.onFilterChange();
|
||||
return;
|
||||
}
|
||||
|
||||
if(!msg.value) {
|
||||
console.log('msg',msg);
|
||||
if(msg.name == '1') sessionStorage.setItem('filter_availability', msg.name);
|
||||
if(msg.name != '1') removeSessionSetting(`filter_${msg.type}_${msg.name}`);
|
||||
//this.props.navigate({
|
||||
// pathname: this.props.location.pathname,
|
||||
// search: `?inStock=${msg.name}`
|
||||
//});
|
||||
} else {
|
||||
if(msg.name == '1') sessionStorage.removeItem('filter_availability');
|
||||
if(msg.name != '1') setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');
|
||||
console.log('msg',msg);
|
||||
//this.props.navigate({
|
||||
// pathname: this.props.location.pathname,
|
||||
// search: this.props.location.search.replace(/inStock=[^&]*/, '')
|
||||
//});
|
||||
}
|
||||
|
||||
this.props.onFilterChange();
|
||||
|
||||
|
||||
}}
|
||||
/>
|
||||
|
||||
{this.generateAttributeFilters()}
|
||||
|
||||
<Filter
|
||||
title="Hersteller"
|
||||
options={this.state.uniqueManufacturerArray}
|
||||
filterType="manufacturer"
|
||||
products={this.props.products}
|
||||
filteredProducts={this.props.filteredProducts}
|
||||
attributes={this.props.attributes}
|
||||
onFilterChange={(msg)=>{
|
||||
if(msg.value) {
|
||||
setSessionSetting(`filter_${msg.type}_${msg.name}`, 'true');
|
||||
} else {
|
||||
removeSessionSetting(`filter_${msg.type}_${msg.name}`);
|
||||
}
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
/>
|
||||
</>)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(ProductFilters);
|
||||
347
src/components/ProductList.js
Normal file
347
src/components/ProductList.js
Normal file
@@ -0,0 +1,347 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Pagination from '@mui/material/Pagination';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Product from './Product.js';
|
||||
import { removeSessionSetting } from '../utils/sessionStorage.js';
|
||||
|
||||
// Sort products by fuzzy similarity to their name/description
|
||||
function sortProductsByFuzzySimilarity(products, searchTerm) {
|
||||
console.log('sortProductsByFuzzySimilarity',products,searchTerm);
|
||||
// Create an array that preserves the product object and its searchable text
|
||||
const productsWithText = products.map(product => {
|
||||
const searchableText = `${product.name || ''} ${product.description || ''}`;
|
||||
return { product, searchableText };
|
||||
});
|
||||
|
||||
// Sort products based on their searchable text similarity
|
||||
productsWithText.sort((a, b) => {
|
||||
const scoreA = getFuzzySimilarityScore(a.searchableText, searchTerm);
|
||||
const scoreB = getFuzzySimilarityScore(b.searchableText, searchTerm);
|
||||
return scoreB - scoreA; // Higher scores first
|
||||
});
|
||||
|
||||
// Return just the sorted product objects
|
||||
return productsWithText.map(item => item.product);
|
||||
}
|
||||
|
||||
// Calculate a similarity score between text and search term
|
||||
function getFuzzySimilarityScore(text, searchTerm) {
|
||||
const searchWords = searchTerm.toLowerCase().split(/\W+/).filter(Boolean);
|
||||
const textWords = text.toLowerCase().split(/\W+/).filter(Boolean);
|
||||
|
||||
let totalScore = 0;
|
||||
for (let searchWord of searchWords) {
|
||||
// Exact matches get highest priority
|
||||
if (textWords.includes(searchWord)) {
|
||||
totalScore += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Partial matches get scored based on similarity
|
||||
let bestMatch = 0;
|
||||
for (let textWord of textWords) {
|
||||
if (textWord.includes(searchWord) || searchWord.includes(textWord)) {
|
||||
const similarity = Math.min(searchWord.length, textWord.length) /
|
||||
Math.max(searchWord.length, textWord.length);
|
||||
if (similarity > bestMatch) bestMatch = similarity;
|
||||
}
|
||||
}
|
||||
totalScore += bestMatch;
|
||||
}
|
||||
|
||||
return totalScore;
|
||||
}
|
||||
|
||||
|
||||
class ProductList extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
viewMode: window.productListViewMode || 'grid',
|
||||
products:[],
|
||||
page: window.productListPage || 1,
|
||||
itemsPerPage: window.productListItemsPerPage || 20,
|
||||
sortBy: window.currentSearchQuery ? 'searchField' : 'name'
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
this.handleSearchQuery = () => {
|
||||
this.setState({ sortBy: window.currentSearchQuery ? 'searchField' : 'name' });
|
||||
};
|
||||
window.addEventListener('search-query-change', this.handleSearchQuery);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('search-query-change', this.handleSearchQuery);
|
||||
}
|
||||
|
||||
handleViewModeChange = (viewMode) => {
|
||||
this.setState({ viewMode });
|
||||
window.productListViewMode = viewMode;
|
||||
}
|
||||
|
||||
handlePageChange = (event, value) => {
|
||||
this.setState({ page: value });
|
||||
window.productListPage = value;
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const currentPageCapacity = this.state.itemsPerPage === 'all' ? Infinity : this.state.itemsPerPage;
|
||||
if(this.props.products.length > 0 ) if (this.props.products.length < (currentPageCapacity * (this.state.page-1)) ) {
|
||||
if(this.state.page != 1) this.setState({ page: 1 });
|
||||
window.productListPage = 1;
|
||||
}
|
||||
}
|
||||
|
||||
handleProductsPerPageChange = (event) => {
|
||||
const newItemsPerPage = event.target.value;
|
||||
const newState = { itemsPerPage: newItemsPerPage };
|
||||
window.productListItemsPerPage = newItemsPerPage;
|
||||
|
||||
if(newItemsPerPage!=='all'){
|
||||
const newTotalPages = Math.ceil(this.props.products.length / newItemsPerPage);
|
||||
if (this.state.page > newTotalPages) {
|
||||
newState.page = newTotalPages;
|
||||
window.productListPage = newTotalPages;
|
||||
}
|
||||
}
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
handleSortChange = (event) => {
|
||||
const sortBy = event.target.value;
|
||||
this.setState({ sortBy });
|
||||
}
|
||||
|
||||
renderPagination = (pages, page) => {
|
||||
return (
|
||||
<Box sx={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'left' }}>
|
||||
{((this.state.itemsPerPage==='all')||(this.props.products.length<this.state.itemsPerPage))?null:
|
||||
<Pagination
|
||||
count={pages}
|
||||
page={page}
|
||||
onChange={this.handlePageChange}
|
||||
color="primary"
|
||||
size={"large"}
|
||||
siblingCount={window.innerWidth < 600 ? 0 : 1}
|
||||
boundaryCount={window.innerWidth < 600 ? 1 : 1}
|
||||
hideNextButton={false}
|
||||
hidePrevButton={false}
|
||||
showFirstButton={window.innerWidth >= 600}
|
||||
showLastButton={window.innerWidth >= 600}
|
||||
sx={{
|
||||
'& .MuiPagination-ul': {
|
||||
flexWrap: 'nowrap',
|
||||
overflowX: 'auto',
|
||||
maxWidth: '100%'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
//console.log('products',this.props.activeAttributeFilters,this.props.activeManufacturerFilters,window.currentSearchQuery,this.state.sortBy);
|
||||
|
||||
const filteredProducts = (this.state.sortBy==='searchField')&&(window.currentSearchQuery)?sortProductsByFuzzySimilarity(this.props.products, window.currentSearchQuery):this.state.sortBy==='name'?this.props.products:this.props.products.sort((a,b)=>{
|
||||
if(this.state.sortBy==='price-low-high'){
|
||||
return a.price-b.price;
|
||||
}
|
||||
if(this.state.sortBy==='price-high-low'){
|
||||
return b.price-a.price;
|
||||
}
|
||||
});
|
||||
const products = this.state.itemsPerPage==='all'?[...filteredProducts]:filteredProducts.slice((this.state.page - 1) * this.state.itemsPerPage , this.state.page * this.state.itemsPerPage);
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%' }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{this.props.activeAttributeFilters.map((filter,index) => (
|
||||
<Chip
|
||||
size="medium"
|
||||
key={index}
|
||||
label={filter.value}
|
||||
onClick={() => {
|
||||
removeSessionSetting(`filter_attribute_${filter.id}`);
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
onDelete={() => {
|
||||
removeSessionSetting(`filter_attribute_${filter.id}`);
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
clickable
|
||||
/>
|
||||
))}
|
||||
{this.props.activeManufacturerFilters.map((filter,index) => (
|
||||
<Chip
|
||||
size="medium"
|
||||
key={index}
|
||||
label={filter.name}
|
||||
onClick={() => {
|
||||
removeSessionSetting(`filter_manufacturer_${filter.value}`);
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
onDelete={() => {
|
||||
removeSessionSetting(`filter_manufacturer_${filter.value}`);
|
||||
this.props.onFilterChange();
|
||||
}}
|
||||
clickable
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
{/* Sort Dropdown */}
|
||||
<FormControl variant="outlined" size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel id="sort-by-label">Sortierung</InputLabel>
|
||||
<Select
|
||||
size="small"
|
||||
labelId="sort-by-label"
|
||||
value={(this.state.sortBy==='searchField')&&(window.currentSearchQuery)?this.state.sortBy:this.state.sortBy==='price-low-high'?this.state.sortBy:this.state.sortBy==='price-low-high'?this.state.sortBy:'name'}
|
||||
onChange={this.handleSortChange}
|
||||
label="Sortierung"
|
||||
MenuProps={{
|
||||
disableScrollLock: true,
|
||||
anchorOrigin: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
},
|
||||
PaperProps: {
|
||||
sx: {
|
||||
maxHeight: 200,
|
||||
boxShadow: 3,
|
||||
mt: 0.5
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="name">Name</MenuItem>
|
||||
{window.currentSearchQuery && <MenuItem value="searchField">Suchbegriff</MenuItem>}
|
||||
<MenuItem value="price-low-high">Preis: Niedrig zu Hoch</MenuItem>
|
||||
<MenuItem value="price-high-low">Preis: Hoch zu Niedrig</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Per Page Dropdown */}
|
||||
<FormControl variant="outlined" size="small" sx={{ minWidth: 100 }}>
|
||||
<InputLabel id="products-per-page-label">pro Seite</InputLabel>
|
||||
<Select
|
||||
labelId="products-per-page-label"
|
||||
value={this.state.itemsPerPage}
|
||||
onChange={this.handleProductsPerPageChange}
|
||||
label="pro Seite"
|
||||
MenuProps={{
|
||||
disableScrollLock: true,
|
||||
anchorOrigin: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
},
|
||||
PaperProps: {
|
||||
sx: {
|
||||
maxHeight: 200,
|
||||
boxShadow: 3,
|
||||
mt: 0.5,
|
||||
position: 'absolute',
|
||||
zIndex: 999
|
||||
}
|
||||
},
|
||||
container: document.getElementById('root')
|
||||
}}
|
||||
>
|
||||
<MenuItem value={20}>20</MenuItem>
|
||||
<MenuItem value={50}>50</MenuItem>
|
||||
<MenuItem value="all">Alle</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
{ this.renderPagination(Math.ceil(filteredProducts.length / this.state.itemsPerPage), this.state.page) }
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{/*this.props.dataType == 'category' && (<>Kategorie: {this.props.dataParam}</>)}*/}
|
||||
{this.props.dataType == 'search' && (<>Suchergebnisse für: "{this.props.dataParam}"</>)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{
|
||||
this.props.totalProductCount==this.props.products.length && this.props.totalProductCount>0 ?
|
||||
`${this.props.totalProductCount} Produkte`
|
||||
:
|
||||
`${this.props.products.length} von ${this.props.totalProductCount} Produkte`
|
||||
}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{products.map((product) => (
|
||||
<Grid
|
||||
key={product.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: { xs: 'stretch', sm: 'center' },
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
<Product
|
||||
id={product.id}
|
||||
name={product.name}
|
||||
seoName={product.seoName}
|
||||
price={product.price}
|
||||
currency={product.currency}
|
||||
available={product.available}
|
||||
manufacturer={product.manufacturer}
|
||||
vat={product.vat}
|
||||
massMenge={product.massMenge}
|
||||
massEinheit={product.massEinheit}
|
||||
incoming={product.incomingDate}
|
||||
neu={product.neu}
|
||||
thc={product.thc}
|
||||
floweringWeeks={product.floweringWeeks}
|
||||
versandklasse={product.versandklasse}
|
||||
weight={product.weight}
|
||||
socket={this.props.socket}
|
||||
pictureList={product.pictureList}
|
||||
availableSupplier={product.availableSupplier}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{this.renderPagination(Math.ceil(filteredProducts.length / this.state.itemsPerPage), this.state.page)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductList;
|
||||
14
src/components/ScrollToTop.js
Normal file
14
src/components/ScrollToTop.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const ScrollToTop = () => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
121
src/components/Stripe.js
Normal file
121
src/components/Stripe.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { Component, useState } from "react";
|
||||
import { Elements, PaymentElement } from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { Button } from "@mui/material";
|
||||
import config from "../config.js";
|
||||
|
||||
import { useStripe, useElements } from "@stripe/react-stripe-js";
|
||||
|
||||
const CheckoutForm = () => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState(null);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/profile?complete`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error.message);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<PaymentElement />
|
||||
<Button variant="contained" disabled={!stripe} style={{ marginTop: "20px" }} type="submit">
|
||||
Bezahlung Abschließen
|
||||
</Button>
|
||||
{errorMessage && <div>{errorMessage}</div>}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
class Stripe extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
stripe: null,
|
||||
loading: true,
|
||||
elements: null,
|
||||
};
|
||||
this.stripePromise = loadStripe(config.stripePublishableKey);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.stripePromise.then((stripe) => {
|
||||
this.setState({ stripe, loading: false });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { clientSecret } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.state.loading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<Elements
|
||||
stripe={this.stripePromise}
|
||||
options={{
|
||||
appearance: {
|
||||
theme: "stripe",
|
||||
variables: {
|
||||
// Core colors matching your green theme
|
||||
colorPrimary: '#2E7D32', // Your primary forest green
|
||||
colorBackground: '#ffffff', // White background (matches your paper color)
|
||||
colorText: '#33691E', // Your primary text color (dark green)
|
||||
colorTextSecondary: '#558B2F', // Your secondary text color
|
||||
colorTextPlaceholder: '#81C784', // Light green for placeholder text
|
||||
colorDanger: '#D32F2F', // Your error color (red)
|
||||
colorSuccess: '#43A047', // Your success color
|
||||
colorWarning: '#FF9800', // Orange for warnings
|
||||
|
||||
// Typography matching your Roboto setup
|
||||
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif",
|
||||
fontSizeBase: '16px', // Base font size for mobile compatibility
|
||||
fontWeightNormal: '400', // Normal Roboto weight
|
||||
fontWeightMedium: '500', // Medium Roboto weight
|
||||
fontWeightBold: '700', // Bold Roboto weight
|
||||
|
||||
// Layout and spacing
|
||||
spacingUnit: '4px', // Consistent spacing
|
||||
borderRadius: '8px', // Rounded corners matching your style
|
||||
|
||||
// Background variations
|
||||
colorBackgroundDeemphasized: '#C8E6C9', // Your light green background
|
||||
|
||||
// Focus and interaction states
|
||||
focusBoxShadow: '0 0 0 2px #4CAF50', // Green focus ring
|
||||
focusOutline: 'none',
|
||||
|
||||
// Icons to match your green theme
|
||||
iconColor: '#558B2F', // Secondary green for icons
|
||||
iconHoverColor: '#2E7D32', // Primary green on hover
|
||||
}
|
||||
},
|
||||
clientSecret: clientSecret,
|
||||
}}
|
||||
>
|
||||
<CheckoutForm />
|
||||
</Elements>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Stripe;
|
||||
258
src/components/ThemeCustomizerDialog.js
Normal file
258
src/components/ThemeCustomizerDialog.js
Normal file
@@ -0,0 +1,258 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
TextField,
|
||||
Chip,
|
||||
Grid,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import PaletteIcon from '@mui/icons-material/Palette';
|
||||
|
||||
const ThemeCustomizerDialog = ({ open, onClose, theme, onThemeChange }) => {
|
||||
const [localTheme, setLocalTheme] = useState(theme);
|
||||
|
||||
// @note Theme customizer for development - allows real-time theme changes
|
||||
useEffect(() => {
|
||||
setLocalTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
const handleColorChange = (path, value) => {
|
||||
const pathArray = path.split('.');
|
||||
const newTheme = { ...localTheme };
|
||||
let current = newTheme;
|
||||
|
||||
for (let i = 0; i < pathArray.length - 1; i++) {
|
||||
current = current[pathArray[i]];
|
||||
}
|
||||
current[pathArray[pathArray.length - 1]] = value;
|
||||
|
||||
setLocalTheme(newTheme);
|
||||
onThemeChange(newTheme);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const resetTheme = () => {
|
||||
const defaultTheme = {
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#2E7D32',
|
||||
light: '#4CAF50',
|
||||
dark: '#1B5E20',
|
||||
},
|
||||
secondary: {
|
||||
main: '#81C784',
|
||||
light: '#A5D6A7',
|
||||
dark: '#66BB6A',
|
||||
},
|
||||
background: {
|
||||
default: '#C8E6C9',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
text: {
|
||||
primary: '#33691E',
|
||||
secondary: '#558B2F',
|
||||
},
|
||||
success: {
|
||||
main: '#43A047',
|
||||
},
|
||||
error: {
|
||||
main: '#D32F2F',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: "'Roboto', 'Helvetica', 'Arial', sans-serif",
|
||||
h4: {
|
||||
fontWeight: 600,
|
||||
color: '#33691E',
|
||||
},
|
||||
},
|
||||
};
|
||||
setLocalTheme(defaultTheme);
|
||||
onThemeChange(defaultTheme);
|
||||
};
|
||||
|
||||
const ColorPicker = ({ label, path, value }) => (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>{label}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TextField
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => handleColorChange(path, e.target.value)}
|
||||
sx={{ width: 50, height: 35 }}
|
||||
/>
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={(e) => handleColorChange(path, e.target.value)}
|
||||
size="small"
|
||||
sx={{ flex: 1, fontSize: '0.75rem' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PaletteIcon />
|
||||
Theme Customizer (Development Mode)
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Chip
|
||||
label="DEV ONLY"
|
||||
color="warning"
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
This tool is only available in development mode for theme customization.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Primary Colors</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Main"
|
||||
path="palette.primary.main"
|
||||
value={localTheme.palette.primary.main}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Light"
|
||||
path="palette.primary.light"
|
||||
value={localTheme.palette.primary.light}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Dark"
|
||||
path="palette.primary.dark"
|
||||
value={localTheme.palette.primary.dark}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Secondary Colors</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Main"
|
||||
path="palette.secondary.main"
|
||||
value={localTheme.palette.secondary.main}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Light"
|
||||
path="palette.secondary.light"
|
||||
value={localTheme.palette.secondary.light}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<ColorPicker
|
||||
label="Dark"
|
||||
path="palette.secondary.dark"
|
||||
value={localTheme.palette.secondary.dark}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Background & Text</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={6}>
|
||||
<ColorPicker
|
||||
label="Background"
|
||||
path="palette.background.default"
|
||||
value={localTheme.palette.background.default}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Paper"
|
||||
path="palette.background.paper"
|
||||
value={localTheme.palette.background.paper}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<ColorPicker
|
||||
label="Text Primary"
|
||||
path="palette.text.primary"
|
||||
value={localTheme.palette.text.primary}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Text Secondary"
|
||||
path="palette.text.secondary"
|
||||
value={localTheme.palette.text.secondary}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Status Colors</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={6}>
|
||||
<ColorPicker
|
||||
label="Success"
|
||||
path="palette.success.main"
|
||||
value={localTheme.palette.success.main}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<ColorPicker
|
||||
label="Error"
|
||||
path="palette.error.main"
|
||||
value={localTheme.palette.error.main}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={resetTheme} color="warning">
|
||||
Reset to Default
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeCustomizerDialog;
|
||||
159
src/components/configurator/ExtrasSelector.js
Normal file
159
src/components/configurator/ExtrasSelector.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { Component } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
|
||||
class ExtrasSelector extends Component {
|
||||
formatPrice(price) {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
renderExtraCard(extra) {
|
||||
const { selectedExtras, onExtraToggle, showImage = true } = this.props;
|
||||
const isSelected = selectedExtras.includes(extra.id);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={extra.id}
|
||||
sx={{
|
||||
height: '100%',
|
||||
border: '2px solid',
|
||||
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
|
||||
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
|
||||
'&:hover': {
|
||||
boxShadow: 5,
|
||||
borderColor: isSelected ? '#2e7d32' : '#90caf9'
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => onExtraToggle(extra.id)}
|
||||
>
|
||||
{showImage && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="160"
|
||||
image={extra.image}
|
||||
alt={extra.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onExtraToggle(extra.id);
|
||||
}}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
'&.Mui-checked': { color: '#2e7d32' },
|
||||
padding: 0
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label=""
|
||||
sx={{ margin: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{this.formatPrice(extra.price)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{extra.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{extra.description}
|
||||
</Typography>
|
||||
|
||||
{isSelected && (
|
||||
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
✓ Hinzugefügt
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { extras, title, subtitle, groupByCategory = true, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
|
||||
|
||||
if (groupByCategory) {
|
||||
// Group extras by category
|
||||
const groupedExtras = extras.reduce((acc, extra) => {
|
||||
if (!acc[extra.category]) {
|
||||
acc[extra.category] = [];
|
||||
}
|
||||
acc[extra.category].push(extra);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{Object.entries(groupedExtras).map(([category, categoryExtras]) => (
|
||||
<Box key={category} sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1976d2' }}>
|
||||
{category}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{categoryExtras.map(extra => (
|
||||
<Grid item {...gridSize} key={extra.id}>
|
||||
{this.renderExtraCard(extra)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render without category grouping
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{extras.map(extra => (
|
||||
<Grid item {...gridSize} key={extra.id}>
|
||||
{this.renderExtraCard(extra)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ExtrasSelector;
|
||||
170
src/components/configurator/ProductSelector.js
Normal file
170
src/components/configurator/ProductSelector.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { Component } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
|
||||
class ProductSelector extends Component {
|
||||
formatPrice(price) {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
renderProductCard(product) {
|
||||
const { selectedValue, onSelect, showImage = true } = this.props;
|
||||
const isSelected = selectedValue === product.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={product.id}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: '2px solid',
|
||||
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
|
||||
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
|
||||
'&:hover': {
|
||||
boxShadow: 6,
|
||||
borderColor: isSelected ? '#2e7d32' : '#90caf9'
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
height: '100%'
|
||||
}}
|
||||
onClick={() => onSelect(product.id)}
|
||||
>
|
||||
{showImage && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="180"
|
||||
image={product.image}
|
||||
alt={product.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{product.description}
|
||||
</Typography>
|
||||
|
||||
{/* Product specific information */}
|
||||
{this.renderProductDetails(product)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{this.formatPrice(product.price)}
|
||||
</Typography>
|
||||
{isSelected && (
|
||||
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
✓ Ausgewählt
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
renderProductDetails(product) {
|
||||
const { productType } = this.props;
|
||||
|
||||
switch (productType) {
|
||||
case 'tent':
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Maße:</strong> {product.dimensions}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Für:</strong> {product.coverage}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'light':
|
||||
return (
|
||||
<Box sx={{ mt: 2, mb: 2 }}>
|
||||
<Chip
|
||||
label={product.wattage}
|
||||
size="small"
|
||||
sx={{ mr: 1, mb: 1, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Chip
|
||||
label={product.coverage}
|
||||
size="small"
|
||||
sx={{ mr: 1, mb: 1, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Chip
|
||||
label={product.spectrum}
|
||||
size="small"
|
||||
sx={{ mr: 1, mb: 1, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Effizienz: ${product.efficiency}`}
|
||||
size="small"
|
||||
sx={{ mb: 1, pointerEvents: 'none' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'ventilation':
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Luftdurchsatz:</strong> {product.airflow}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Lautstärke:</strong> {product.noiseLevel}
|
||||
</Typography>
|
||||
{product.includes && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>Beinhaltet:</strong>
|
||||
</Typography>
|
||||
{product.includes.map((item, index) => (
|
||||
<Typography key={index} variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
• {item}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { products, title, subtitle, gridSize = { xs: 12, sm: 6, md: 4 } } = this.props;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{products.map(product => (
|
||||
<Grid item {...gridSize} key={product.id}>
|
||||
{this.renderProductCard(product)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ProductSelector;
|
||||
241
src/components/configurator/TentShapeSelector.js
Normal file
241
src/components/configurator/TentShapeSelector.js
Normal file
@@ -0,0 +1,241 @@
|
||||
import React, { Component } from 'react';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
|
||||
class TentShapeSelector extends Component {
|
||||
// Generate plant layout based on tent shape
|
||||
generatePlantLayout(shapeId) {
|
||||
const layouts = {
|
||||
'60x60': [
|
||||
{ x: 50, y: 50, size: 18 } // 1 large plant centered
|
||||
],
|
||||
'80x80': [
|
||||
{ x: 35, y: 35, size: 12 }, // 2x2 = 4 plants
|
||||
{ x: 65, y: 35, size: 12 },
|
||||
{ x: 35, y: 65, size: 12 },
|
||||
{ x: 65, y: 65, size: 12 }
|
||||
],
|
||||
'100x100': [
|
||||
{ x: 22, y: 22, size: 10 }, // 3x3 = 9 plants
|
||||
{ x: 50, y: 22, size: 10 },
|
||||
{ x: 78, y: 22, size: 10 },
|
||||
{ x: 22, y: 50, size: 10 },
|
||||
{ x: 50, y: 50, size: 10 },
|
||||
{ x: 78, y: 50, size: 10 },
|
||||
{ x: 22, y: 78, size: 10 },
|
||||
{ x: 50, y: 78, size: 10 },
|
||||
{ x: 78, y: 78, size: 10 }
|
||||
],
|
||||
'120x60': [
|
||||
{ x: 30, y: 50, size: 14 }, // 1x3 = 3 larger plants
|
||||
{ x: 50, y: 50, size: 14 },
|
||||
{ x: 70, y: 50, size: 14 }
|
||||
]
|
||||
};
|
||||
|
||||
return layouts[shapeId] || [];
|
||||
}
|
||||
|
||||
renderShapeCard(shape) {
|
||||
const { selectedShape, onShapeSelect } = this.props;
|
||||
const isSelected = selectedShape === shape.id;
|
||||
|
||||
const plants = this.generatePlantLayout(shape.id);
|
||||
|
||||
// Make visual sizes proportional to actual dimensions
|
||||
let visualWidth, visualHeight;
|
||||
switch(shape.id) {
|
||||
case '60x60':
|
||||
visualWidth = 90;
|
||||
visualHeight = 90;
|
||||
break;
|
||||
case '80x80':
|
||||
visualWidth = 110;
|
||||
visualHeight = 110;
|
||||
break;
|
||||
case '100x100':
|
||||
visualWidth = 130;
|
||||
visualHeight = 130;
|
||||
break;
|
||||
case '120x60':
|
||||
visualWidth = 140;
|
||||
visualHeight = 80;
|
||||
break;
|
||||
default:
|
||||
visualWidth = 120;
|
||||
visualHeight = 120;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={shape.id}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: '3px solid',
|
||||
borderColor: isSelected ? '#2e7d32' : '#e0e0e0',
|
||||
backgroundColor: isSelected ? '#f1f8e9' : '#ffffff',
|
||||
'&:hover': {
|
||||
boxShadow: 8,
|
||||
borderColor: isSelected ? '#2e7d32' : '#90caf9',
|
||||
transform: 'translateY(-2px)'
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
height: '100%',
|
||||
minHeight: 300
|
||||
}}
|
||||
onClick={() => onShapeSelect(shape.id)}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', p: 3 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{shape.name}
|
||||
</Typography>
|
||||
|
||||
{/* Enhanced visual representation with plant layout */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: visualHeight,
|
||||
mb: 2,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${visualWidth}px`,
|
||||
height: `${visualHeight}px`,
|
||||
border: '3px solid #2e7d32',
|
||||
borderRadius: 2,
|
||||
backgroundColor: isSelected ? '#e8f5e8' : '#f5f5f5',
|
||||
position: 'relative',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Grid pattern */}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<pattern id={`grid-${shape.id}`} width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M 10 0 L 0 0 0 10" fill="none" stroke="#e0e0e0" strokeWidth="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#grid-${shape.id})`} />
|
||||
|
||||
{/* Plants */}
|
||||
{plants.map((plant, index) => (
|
||||
<circle
|
||||
key={index}
|
||||
cx={`${plant.x}%`}
|
||||
cy={`${plant.y}%`}
|
||||
r={plant.size}
|
||||
fill="#4caf50"
|
||||
fillOpacity="0.8"
|
||||
stroke="#2e7d32"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Dimensions label */}
|
||||
<Typography variant="caption" sx={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontWeight: 'bold',
|
||||
color: '#2e7d32',
|
||||
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||
px: 1,
|
||||
borderRadius: 1,
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
{shape.footprint}
|
||||
</Typography>
|
||||
|
||||
{/* Plant count label */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
backgroundColor: 'rgba(46, 125, 50, 0.9)',
|
||||
color: 'white',
|
||||
px: 1,
|
||||
borderRadius: 1,
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{plants.length} 🌱
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{shape.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Chip
|
||||
label={`${shape.minPlants}-${shape.maxPlants} Pflanzen`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: isSelected ? '#2e7d32' : '#f0f0f0',
|
||||
color: isSelected ? 'white' : 'inherit',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
fontWeight: 'bold',
|
||||
opacity: isSelected ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease'
|
||||
}}
|
||||
>
|
||||
✓ Ausgewählt
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { tentShapes, title, subtitle } = this.props;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Grid container spacing={3}>
|
||||
{tentShapes.map(shape => (
|
||||
<Grid item xs={12} sm={6} md={3} key={shape.id}>
|
||||
{this.renderShapeCard(shape)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TentShapeSelector;
|
||||
3
src/components/configurator/index.js
Normal file
3
src/components/configurator/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as TentShapeSelector } from './TentShapeSelector.js';
|
||||
export { default as ProductSelector } from './ProductSelector.js';
|
||||
export { default as ExtrasSelector } from './ExtrasSelector.js';
|
||||
198
src/components/header/ButtonGroup.js
Normal file
198
src/components/header/ButtonGroup.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import LoginComponent from '../LoginComponent.js';
|
||||
import CartDropdown from '../CartDropdown.js';
|
||||
import { isUserLoggedIn } from '../LoginComponent.js';
|
||||
|
||||
function getBadgeNumber() {
|
||||
let count = 0;
|
||||
if (Array.isArray(window.cart)) for (const item of window.cart) {
|
||||
if (item.quantity) count += item.quantity;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
class ButtonGroup extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isCartOpen: false,
|
||||
badgeNumber: getBadgeNumber()
|
||||
};
|
||||
this.isUpdatingFromSocket = false; // @note Flag to prevent socket loop
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.cart = () => {
|
||||
// @note Only emit if socket exists, is connected, AND the update didn't come from socket
|
||||
if (this.props.socket && this.props.socket.connected && !this.isUpdatingFromSocket) {
|
||||
this.props.socket.emit('updateCart', window.cart);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
badgeNumber: getBadgeNumber()
|
||||
});
|
||||
};
|
||||
window.addEventListener('cart', this.cart);
|
||||
|
||||
// Add event listener for the toggle-cart event from AddToCartButton
|
||||
this.toggleCartListener = () => this.toggleCart();
|
||||
window.addEventListener('toggle-cart', this.toggleCartListener);
|
||||
|
||||
// Add socket listeners if socket is available and connected
|
||||
this.addSocketListeners();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected) {
|
||||
// Socket just connected, add listeners
|
||||
this.addSocketListeners();
|
||||
} else if (wasConnected && !isNowConnected) {
|
||||
// Socket just disconnected, remove listeners
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('cart', this.cart);
|
||||
window.removeEventListener('toggle-cart', this.toggleCartListener);
|
||||
this.removeSocketListeners();
|
||||
}
|
||||
|
||||
addSocketListeners = () => {
|
||||
if (this.props.socket && this.props.socket.connected) {
|
||||
// Remove existing listeners first to avoid duplicates
|
||||
this.removeSocketListeners();
|
||||
this.props.socket.on('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListeners = () => {
|
||||
if (this.props.socket) {
|
||||
this.props.socket.off('cartUpdated', this.handleCartUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
handleCartUpdated = (id,user,cart) => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(storedUser);
|
||||
if(user && parsedUser &&user.email == parsedUser.email){
|
||||
// @note Set flag before updating cart to prevent socket loop
|
||||
this.isUpdatingFromSocket = true;
|
||||
window.cart = cart;
|
||||
this.setState({
|
||||
badgeNumber: getBadgeNumber()
|
||||
});
|
||||
// @note Reset flag after a short delay to allow for any synchronous events
|
||||
setTimeout(() => {
|
||||
this.isUpdatingFromSocket = false;
|
||||
}, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing user from sessionStorage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
toggleCart = () => {
|
||||
this.setState(prevState => ({
|
||||
isCartOpen: !prevState.isCartOpen
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { socket, navigate } = this.props;
|
||||
const { isCartOpen } = this.state;
|
||||
const cartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 1 } }}>
|
||||
|
||||
|
||||
<LoginComponent socket={socket} />
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={this.toggleCart}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<Badge badgeContent={this.state.badgeNumber} color="error">
|
||||
<ShoppingCartIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
<Drawer
|
||||
anchor="left"
|
||||
open={isCartOpen}
|
||||
onClose={this.toggleCart}
|
||||
disableScrollLock={true}
|
||||
>
|
||||
<Box sx={{ width: 420, p: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={this.toggleCart}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.dark',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6">Warenkorb</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<CartDropdown cartItems={cartItems} socket={socket} onClose={this.toggleCart} onCheckout={()=>{
|
||||
/*open the Drawer inside <LoginComponent */
|
||||
|
||||
if (isUserLoggedIn().isLoggedIn) {
|
||||
this.toggleCart(); // Close the cart drawer
|
||||
navigate('/profile');
|
||||
} else if (window.openLoginDrawer) {
|
||||
window.openLoginDrawer(); // Call global function to open login drawer
|
||||
this.toggleCart(); // Close the cart drawer
|
||||
} else {
|
||||
console.error('openLoginDrawer function not available');
|
||||
}
|
||||
}}/>
|
||||
|
||||
</Box>
|
||||
</Drawer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper for ButtonGroup to provide navigate function
|
||||
const ButtonGroupWithRouter = (props) => {
|
||||
const navigate = useNavigate();
|
||||
return <ButtonGroup {...props} navigate={navigate} />;
|
||||
};
|
||||
|
||||
export default ButtonGroupWithRouter;
|
||||
481
src/components/header/CategoryList.js
Normal file
481
src/components/header/CategoryList.js
Normal file
@@ -0,0 +1,481 @@
|
||||
import React, { Component, Profiler } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Container from "@mui/material/Container";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Link } from "react-router-dom";
|
||||
import HomeIcon from "@mui/icons-material/Home";
|
||||
|
||||
class CategoryList extends Component {
|
||||
findCategoryById = (category, targetId) => {
|
||||
if (!category) return null;
|
||||
|
||||
if (category.seoName === targetId) {
|
||||
return category;
|
||||
}
|
||||
|
||||
if (category.children) {
|
||||
for (let child of category.children) {
|
||||
const found = this.findCategoryById(child, targetId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
getPathToCategory = (category, targetId, currentPath = []) => {
|
||||
if (!category) return null;
|
||||
|
||||
const newPath = [...currentPath, category];
|
||||
|
||||
if (category.seoName === targetId) {
|
||||
return newPath;
|
||||
}
|
||||
|
||||
if (category.children) {
|
||||
for (let child of category.children) {
|
||||
const found = this.getPathToCategory(child, targetId, newPath);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Check for cached data during SSR/initial render
|
||||
let initialState = {
|
||||
categoryTree: null,
|
||||
level1Categories: [], // Children of category 209 (Home) - always shown
|
||||
level2Categories: [], // Children of active level 1 category
|
||||
level3Categories: [], // Children of active level 2 category
|
||||
activePath: [], // Array of active category objects for each level
|
||||
fetchedCategories: false,
|
||||
};
|
||||
|
||||
// Try to get cached data for SSR
|
||||
try {
|
||||
// @note Check both global.window (SSR) and window (browser) for cache
|
||||
const productCache = (typeof global !== "undefined" && global.window && global.window.productCache) ||
|
||||
(typeof window !== "undefined" && window.productCache);
|
||||
|
||||
if (productCache) {
|
||||
const cacheKey = "categoryTree_209";
|
||||
const cachedData = productCache[cacheKey];
|
||||
if (cachedData && cachedData.categoryTree) {
|
||||
const { categoryTree, timestamp } = cachedData;
|
||||
const cacheAge = Date.now() - timestamp;
|
||||
const tenMinutes = 10 * 60 * 1000;
|
||||
|
||||
// Use cached data if it's fresh
|
||||
if (cacheAge < tenMinutes) {
|
||||
initialState.categoryTree = categoryTree;
|
||||
initialState.fetchedCategories = true;
|
||||
|
||||
// Process category tree to set up navigation
|
||||
const level1Categories =
|
||||
categoryTree && categoryTree.id === 209
|
||||
? categoryTree.children || []
|
||||
: [];
|
||||
initialState.level1Categories = level1Categories;
|
||||
|
||||
// Process active category path if needed
|
||||
if (props.activeCategoryId) {
|
||||
const activeCategory = this.findCategoryById(
|
||||
categoryTree,
|
||||
props.activeCategoryId
|
||||
);
|
||||
if (activeCategory) {
|
||||
const pathToActive = this.getPathToCategory(
|
||||
categoryTree,
|
||||
props.activeCategoryId
|
||||
);
|
||||
initialState.activePath = pathToActive
|
||||
? pathToActive.slice(1)
|
||||
: [];
|
||||
|
||||
if (initialState.activePath.length >= 1) {
|
||||
const level1Category = initialState.activePath[0];
|
||||
initialState.level2Categories = level1Category.children || [];
|
||||
}
|
||||
|
||||
if (initialState.activePath.length >= 2) {
|
||||
const level2Category = initialState.activePath[1];
|
||||
initialState.level3Categories = level2Category.children || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error reading cache in constructor:", err);
|
||||
}
|
||||
|
||||
this.state = initialState;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchCategories();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Handle socket connection changes
|
||||
const wasConnected = prevProps.socket && prevProps.socket.connected;
|
||||
const isNowConnected = this.props.socket && this.props.socket.connected;
|
||||
|
||||
if (!wasConnected && isNowConnected && !this.state.fetchedCategories) {
|
||||
// Socket just connected and we haven't fetched categories yet
|
||||
this.setState(
|
||||
{
|
||||
fetchedCategories: false,
|
||||
},
|
||||
() => {
|
||||
this.fetchCategories();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// If activeCategoryId changes, update subcategories
|
||||
if (
|
||||
prevProps.activeCategoryId !== this.props.activeCategoryId &&
|
||||
this.state.categoryTree
|
||||
) {
|
||||
this.processCategoryTree(this.state.categoryTree);
|
||||
}
|
||||
}
|
||||
|
||||
fetchCategories = () => {
|
||||
const { socket } = this.props;
|
||||
if (!socket || !socket.connected) {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
// The componentDidUpdate will retry when socket connects
|
||||
console.log("Socket not connected yet, waiting for connection to fetch categories");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.fetchedCategories) {
|
||||
//console.log('Categories already fetched, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize global cache object if it doesn't exist
|
||||
// @note Handle both SSR (global.window) and browser (window) environments
|
||||
const windowObj = (typeof global !== "undefined" && global.window) ||
|
||||
(typeof window !== "undefined" && window);
|
||||
|
||||
if (windowObj && !windowObj.productCache) {
|
||||
windowObj.productCache = {};
|
||||
}
|
||||
|
||||
// Check if we have a valid cache in the global object
|
||||
try {
|
||||
const cacheKey = "categoryTree_209";
|
||||
const cachedData = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
|
||||
if (cachedData) {
|
||||
const { categoryTree, fetching } = cachedData;
|
||||
//const cacheAge = Date.now() - timestamp;
|
||||
//const tenMinutes = 10 * 60 * 1000; // 10 minutes in milliseconds
|
||||
|
||||
// If data is currently being fetched, wait for it
|
||||
if (fetching) {
|
||||
//console.log('CategoryList: Data is being fetched, waiting...');
|
||||
const checkInterval = setInterval(() => {
|
||||
const currentCache = windowObj && windowObj.productCache ? windowObj.productCache[cacheKey] : null;
|
||||
if (currentCache && !currentCache.fetching) {
|
||||
clearInterval(checkInterval);
|
||||
if (currentCache.categoryTree) {
|
||||
this.processCategoryTree(currentCache.categoryTree);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// If cache is less than 10 minutes old, use it
|
||||
if (/*cacheAge < tenMinutes &&*/ categoryTree) {
|
||||
//console.log('Using cached category tree, age:', Math.round(cacheAge/1000), 'seconds');
|
||||
// Defer processing to next tick to avoid blocking
|
||||
//setTimeout(() => {
|
||||
this.processCategoryTree(categoryTree);
|
||||
//}, 0);
|
||||
//return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error reading from cache:", err);
|
||||
}
|
||||
|
||||
// Mark as being fetched to prevent concurrent calls
|
||||
const cacheKey = "categoryTree_209";
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
fetching: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
this.setState({ fetchedCategories: true });
|
||||
|
||||
//console.log('CategoryList: Fetching categories from socket');
|
||||
socket.emit("categoryList", { categoryId: 209 }, (response) => {
|
||||
if (response && response.categoryTree) {
|
||||
//console.log('Category tree received:', response.categoryTree);
|
||||
|
||||
// Store in global cache with timestamp
|
||||
try {
|
||||
const cacheKey = "categoryTree_209";
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
categoryTree: response.categoryTree,
|
||||
timestamp: Date.now(),
|
||||
fetching: false,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error writing to cache:", err);
|
||||
}
|
||||
|
||||
this.processCategoryTree(response.categoryTree);
|
||||
} else {
|
||||
try {
|
||||
const cacheKey = "categoryTree_209";
|
||||
if (windowObj && windowObj.productCache) {
|
||||
windowObj.productCache[cacheKey] = {
|
||||
categoryTree: null,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error writing to cache:", err);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
categoryTree: null,
|
||||
level1Categories: [],
|
||||
level2Categories: [],
|
||||
level3Categories: [],
|
||||
activePath: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
processCategoryTree = (categoryTree) => {
|
||||
// Level 1 categories are always the children of category 209 (Home)
|
||||
const level1Categories =
|
||||
categoryTree && categoryTree.id === 209
|
||||
? categoryTree.children || []
|
||||
: [];
|
||||
|
||||
// Build the navigation path and determine what to show at each level
|
||||
let level2Categories = [];
|
||||
let level3Categories = [];
|
||||
let activePath = [];
|
||||
|
||||
if (this.props.activeCategoryId) {
|
||||
const activeCategory = this.findCategoryById(
|
||||
categoryTree,
|
||||
this.props.activeCategoryId
|
||||
);
|
||||
if (activeCategory) {
|
||||
// Build the path from root to active category
|
||||
const pathToActive = this.getPathToCategory(
|
||||
categoryTree,
|
||||
this.props.activeCategoryId
|
||||
);
|
||||
activePath = pathToActive.slice(1); // Remove root (209) from path
|
||||
|
||||
// Determine what to show at each level based on the path depth
|
||||
if (activePath.length >= 1) {
|
||||
// Show children of the level 1 category
|
||||
const level1Category = activePath[0];
|
||||
level2Categories = level1Category.children || [];
|
||||
}
|
||||
|
||||
if (activePath.length >= 2) {
|
||||
// Show children of the level 2 category
|
||||
const level2Category = activePath[1];
|
||||
level3Categories = level2Category.children || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
categoryTree,
|
||||
level1Categories,
|
||||
level2Categories,
|
||||
level3Categories,
|
||||
activePath,
|
||||
fetchedCategories: true,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { level1Categories, level2Categories, level3Categories, activePath } =
|
||||
this.state;
|
||||
|
||||
const renderCategoryRow = (categories, level = 1) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
flexWrap: "nowrap",
|
||||
overflowX: "auto",
|
||||
py: 0.5, // Add vertical padding to prevent border clipping
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none",
|
||||
},
|
||||
scrollbarWidth: "none",
|
||||
msOverflowStyle: "none",
|
||||
}}
|
||||
>
|
||||
{level === 1 && (
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
color="inherit"
|
||||
size="small"
|
||||
aria-label="Zur Startseite"
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: "normal",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
mx: 0.5,
|
||||
my: 0.25, // Add consistent vertical margin to account for borders
|
||||
minWidth: "auto",
|
||||
border: "2px solid transparent", // Always have border space
|
||||
borderRadius: 1, // Always have border radius
|
||||
...(this.props.activeCategoryId === null && {
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
|
||||
transform: "translateY(-2px)",
|
||||
bgcolor: "rgba(255,255,255,0.25)",
|
||||
borderColor: "rgba(255,255,255,0.6)", // Change border color instead of adding border
|
||||
fontWeight: "bold",
|
||||
opacity: 1,
|
||||
}),
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
bgcolor: "rgba(255,255,255,0.15)",
|
||||
transform: "translateY(-1px)",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.3)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<HomeIcon sx={{ fontSize: "1rem" }} />
|
||||
</Button>
|
||||
)}
|
||||
{this.state.fetchedCategories && categories.length > 0 ? (
|
||||
<>
|
||||
{categories.map((category) => {
|
||||
// Determine if this category is active at this level
|
||||
const isActiveAtThisLevel =
|
||||
activePath[level - 1] &&
|
||||
activePath[level - 1].id === category.id;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
component={Link}
|
||||
to={`/Kategorie/${category.seoName}`}
|
||||
color="inherit"
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: "normal",
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.9,
|
||||
mx: 0.5,
|
||||
my: 0.25, // Add consistent vertical margin to account for borders
|
||||
border: "2px solid transparent", // Always have border space
|
||||
borderRadius: 1, // Always have border radius
|
||||
...(isActiveAtThisLevel && {
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
|
||||
transform: "translateY(-2px)",
|
||||
bgcolor: "rgba(255,255,255,0.25)",
|
||||
borderColor: "rgba(255,255,255,0.6)", // Change border color instead of adding border
|
||||
fontWeight: "bold",
|
||||
opacity: 1,
|
||||
}),
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
bgcolor: "rgba(255,255,255,0.15)",
|
||||
transform: "translateY(-1px)",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.3)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
level === 1 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="inherit"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
height: "30px", // Match small button height
|
||||
px: 1,
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const onRenderCallback = (id, phase, actualDuration) => {
|
||||
if (actualDuration > 50) {
|
||||
console.warn(
|
||||
`CategoryList render took ${actualDuration}ms in ${phase} phase`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Profiler id="CategoryList" onRender={onRenderCallback}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
bgcolor: "primary.dark",
|
||||
display: { xs: "none", md: "block" },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg" sx={{ px: 2 }}>
|
||||
{/* Level 1 Categories Row - Always shown */}
|
||||
{renderCategoryRow(level1Categories, 1)}
|
||||
|
||||
{/* Level 2 Categories Row - Show when level 1 is selected */}
|
||||
{level2Categories.length > 0 && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{renderCategoryRow(level2Categories, 2)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Level 3 Categories Row - Show when level 2 is selected */}
|
||||
{level3Categories.length > 0 && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{renderCategoryRow(level3Categories, 3)}
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
</Profiler>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CategoryList;
|
||||
27
src/components/header/Logo.js
Normal file
27
src/components/header/Logo.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const Logo = () => {
|
||||
return (
|
||||
<Box
|
||||
component={Link}
|
||||
to="/"
|
||||
aria-label="Zur Startseite"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/assets/images/sh.png"
|
||||
alt="SH Logo"
|
||||
style={{ height: "45px" }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
310
src/components/header/SearchBar.js
Normal file
310
src/components/header/SearchBar.js
Normal file
@@ -0,0 +1,310 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
|
||||
const SearchBar = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const socket = React.useContext(SocketContext);
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
// State management
|
||||
const [searchQuery, setSearchQuery] = React.useState(
|
||||
searchParams.get("q") || ""
|
||||
);
|
||||
const [suggestions, setSuggestions] = React.useState([]);
|
||||
const [showSuggestions, setShowSuggestions] = React.useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(-1);
|
||||
const [loadingSuggestions, setLoadingSuggestions] = React.useState(false);
|
||||
|
||||
// Refs for debouncing and timers
|
||||
const debounceTimerRef = React.useRef(null);
|
||||
const autocompleteTimerRef = React.useRef(null);
|
||||
const isFirstKeystrokeRef = React.useRef(true);
|
||||
const inputRef = React.useRef(null);
|
||||
const suggestionBoxRef = React.useRef(null);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
delete window.currentSearchQuery;
|
||||
setShowSuggestions(false);
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSearchState = (value) => {
|
||||
setSearchQuery(value);
|
||||
|
||||
// Dispatch global custom event with search query value
|
||||
const searchEvent = new CustomEvent("search-query-change", {
|
||||
detail: { query: value },
|
||||
});
|
||||
// Store the current search query in the window object
|
||||
window.currentSearchQuery = value;
|
||||
window.dispatchEvent(searchEvent);
|
||||
};
|
||||
|
||||
// @note Autocomplete function using getSearchProducts Socket.io API - returns objects with name and seoName
|
||||
const fetchAutocomplete = React.useCallback(
|
||||
(query) => {
|
||||
if (!socket || !query || query.length < 2) {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
setLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSuggestions(true);
|
||||
|
||||
socket.emit(
|
||||
"getSearchProducts",
|
||||
{
|
||||
query: query.trim(),
|
||||
limit: 8,
|
||||
},
|
||||
(response) => {
|
||||
setLoadingSuggestions(false);
|
||||
|
||||
if (response && response.products) {
|
||||
// getSearchProducts returns response.products array
|
||||
const suggestions = response.products.slice(0, 8); // Limit to 8 suggestions
|
||||
setSuggestions(suggestions);
|
||||
setShowSuggestions(suggestions.length > 0);
|
||||
setSelectedIndex(-1); // Reset selection
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
console.log("getSearchProducts failed or no products:", response);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[socket]
|
||||
);
|
||||
|
||||
const handleSearchChange = (e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
// Always update the input field immediately for responsiveness
|
||||
setSearchQuery(value);
|
||||
|
||||
// Clear any existing timers
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (autocompleteTimerRef.current) {
|
||||
clearTimeout(autocompleteTimerRef.current);
|
||||
}
|
||||
|
||||
// Set the debounce timer for search state update
|
||||
const delay = isFirstKeystrokeRef.current ? 100 : 200;
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
updateSearchState(value);
|
||||
isFirstKeystrokeRef.current = false;
|
||||
|
||||
// Reset first keystroke flag after 1 second of inactivity
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
isFirstKeystrokeRef.current = true;
|
||||
}, 1000);
|
||||
}, delay);
|
||||
|
||||
// Set autocomplete timer with longer delay to reduce API calls
|
||||
autocompleteTimerRef.current = setTimeout(() => {
|
||||
fetchAutocomplete(value);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Handle keyboard navigation in suggestions
|
||||
const handleKeyDown = (e) => {
|
||||
if (!showSuggestions || suggestions.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < suggestions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||
const selectedSuggestion = suggestions[selectedIndex];
|
||||
setSearchQuery(selectedSuggestion.name);
|
||||
updateSearchState(selectedSuggestion.name);
|
||||
setShowSuggestions(false);
|
||||
navigate(`/Artikel/${selectedSuggestion.seoName}`);
|
||||
} else {
|
||||
handleSearch(e);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle suggestion click - navigate to product page directly
|
||||
const handleSuggestionClick = (suggestion) => {
|
||||
setSearchQuery(suggestion.name);
|
||||
updateSearchState(suggestion.name);
|
||||
setShowSuggestions(false);
|
||||
navigate(`/Artikel/${suggestion.seoName}`);
|
||||
};
|
||||
|
||||
// Handle input focus
|
||||
const handleFocus = () => {
|
||||
if (suggestions.length > 0 && searchQuery.length >= 2) {
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle input blur with delay to allow suggestion clicks
|
||||
const handleBlur = () => {
|
||||
setTimeout(() => {
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Clean up timers on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (autocompleteTimerRef.current) {
|
||||
clearTimeout(autocompleteTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Close suggestions when clicking outside
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (
|
||||
suggestionBoxRef.current &&
|
||||
!suggestionBoxRef.current.contains(event.target) &&
|
||||
!inputRef.current?.contains(event.target)
|
||||
) {
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleSearch}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
mx: { xs: 1, sm: 2, md: 4 },
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
ref={inputRef}
|
||||
placeholder="Produkte suchen..."
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: loadingSuggestions && (
|
||||
<InputAdornment position="end">
|
||||
<CircularProgress size={16} />
|
||||
</InputAdornment>
|
||||
),
|
||||
sx: { borderRadius: 2, bgcolor: "background.paper" },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Autocomplete Suggestions Dropdown */}
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<Paper
|
||||
ref={suggestionBoxRef}
|
||||
elevation={4}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1300,
|
||||
maxHeight: "300px",
|
||||
overflow: "auto",
|
||||
mt: 0.5,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<List disablePadding>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<ListItem
|
||||
key={suggestion.seoName || index}
|
||||
button
|
||||
selected={index === selectedIndex}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
backgroundColor: "action.hover",
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: "action.selected",
|
||||
"&:hover": {
|
||||
backgroundColor: "action.selected",
|
||||
},
|
||||
},
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" noWrap>
|
||||
{suggestion.name}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
4
src/components/header/index.js
Normal file
4
src/components/header/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Logo } from './Logo.js';
|
||||
export { default as SearchBar } from './SearchBar.js';
|
||||
export { default as ButtonGroupWithRouter } from './ButtonGroup.js';
|
||||
export { default as CategoryList } from './CategoryList.js';
|
||||
138
src/components/profile/AddressForm.js
Normal file
138
src/components/profile/AddressForm.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import React from "react";
|
||||
import { Box, TextField, Typography } from "@mui/material";
|
||||
|
||||
const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
|
||||
// Helper function to determine if a required field should show error styling
|
||||
const getRequiredFieldError = (fieldName, value) => {
|
||||
const isEmpty = !value || value.trim() === "";
|
||||
return isEmpty;
|
||||
};
|
||||
|
||||
// Helper function to get label styling for required fields
|
||||
const getRequiredFieldLabelSx = (fieldName, value) => {
|
||||
const showError = getRequiredFieldError(fieldName, value);
|
||||
return showError
|
||||
? {
|
||||
"&.MuiInputLabel-shrink": {
|
||||
color: "#d32f2f", // Material-UI error color
|
||||
},
|
||||
}
|
||||
: {};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Vorname"
|
||||
name="firstName"
|
||||
value={address.firstName}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}FirstName`]}
|
||||
helperText={errors[`${namePrefix}FirstName`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("firstName", address.firstName),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Nachname"
|
||||
name="lastName"
|
||||
value={address.lastName}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}LastName`]}
|
||||
helperText={errors[`${namePrefix}LastName`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("lastName", address.lastName),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Adresszusatz"
|
||||
name="addressAddition"
|
||||
value={address.addressAddition || ""}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
label="Straße"
|
||||
name="street"
|
||||
value={address.street}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}Street`]}
|
||||
helperText={errors[`${namePrefix}Street`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("street", address.street),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Hausnummer"
|
||||
name="houseNumber"
|
||||
value={address.houseNumber}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}HouseNumber`]}
|
||||
helperText={errors[`${namePrefix}HouseNumber`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("houseNumber", address.houseNumber),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="PLZ"
|
||||
name="postalCode"
|
||||
value={address.postalCode}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}PostalCode`]}
|
||||
helperText={errors[`${namePrefix}PostalCode`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("postalCode", address.postalCode),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Stadt"
|
||||
name="city"
|
||||
value={address.city}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
error={!!errors[`${namePrefix}City`]}
|
||||
helperText={errors[`${namePrefix}City`]}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
sx: getRequiredFieldLabelSx("city", address.city),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Land"
|
||||
name="country"
|
||||
value={address.country}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
disabled
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddressForm;
|
||||
510
src/components/profile/CartTab.js
Normal file
510
src/components/profile/CartTab.js
Normal file
@@ -0,0 +1,510 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Typography, Button } from "@mui/material";
|
||||
import CartDropdown from "../CartDropdown.js";
|
||||
import CheckoutForm from "./CheckoutForm.js";
|
||||
import PaymentConfirmationDialog from "./PaymentConfirmationDialog.js";
|
||||
import OrderProcessingService from "./OrderProcessingService.js";
|
||||
import CheckoutValidation from "./CheckoutValidation.js";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
|
||||
class CartTab extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const initialCartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
const initialDeliveryMethod = CheckoutValidation.shouldForcePickupDelivery(initialCartItems) ? "Abholung" : "DHL";
|
||||
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(initialDeliveryMethod, initialCartItems, 0);
|
||||
|
||||
this.state = {
|
||||
isCheckingOut: false,
|
||||
cartItems: initialCartItems,
|
||||
deliveryMethod: initialDeliveryMethod,
|
||||
paymentMethod: optimalPaymentMethod,
|
||||
invoiceAddress: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
addressAddition: "",
|
||||
street: "",
|
||||
houseNumber: "",
|
||||
postalCode: "",
|
||||
city: "",
|
||||
country: "Deutschland",
|
||||
},
|
||||
deliveryAddress: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
addressAddition: "",
|
||||
street: "",
|
||||
houseNumber: "",
|
||||
postalCode: "",
|
||||
city: "",
|
||||
country: "Deutschland",
|
||||
},
|
||||
useSameAddress: true,
|
||||
saveAddressForFuture: true,
|
||||
addressFormErrors: {},
|
||||
termsAccepted: false,
|
||||
isCompletingOrder: false,
|
||||
completionError: null,
|
||||
note: "",
|
||||
stripeClientSecret: null,
|
||||
showStripePayment: false,
|
||||
StripeComponent: null,
|
||||
isLoadingStripe: false,
|
||||
showPaymentConfirmation: false,
|
||||
orderCompleted: false,
|
||||
originalCartItems: []
|
||||
};
|
||||
|
||||
// Initialize order processing service
|
||||
this.orderService = new OrderProcessingService(
|
||||
() => this.context,
|
||||
this.setState.bind(this)
|
||||
);
|
||||
this.orderService.getState = () => this.state;
|
||||
this.orderService.setOrderSuccessCallback(this.props.onOrderSuccess);
|
||||
}
|
||||
|
||||
// @note Add method to fetch and apply order template prefill data
|
||||
fetchOrderTemplate = () => {
|
||||
if (this.context && this.context.connected) {
|
||||
this.context.emit('getOrderTemplate', (response) => {
|
||||
if (response.success && response.orderTemplate) {
|
||||
const template = response.orderTemplate;
|
||||
|
||||
// Map the template fields to our state structure
|
||||
const invoiceAddress = {
|
||||
firstName: template.invoice_address_name ? template.invoice_address_name.split(' ')[0] || "" : "",
|
||||
lastName: template.invoice_address_name ? template.invoice_address_name.split(' ').slice(1).join(' ') || "" : "",
|
||||
addressAddition: template.invoice_address_line2 || "",
|
||||
street: template.invoice_address_street || "",
|
||||
houseNumber: template.invoice_address_house_number || "",
|
||||
postalCode: template.invoice_address_postal_code || "",
|
||||
city: template.invoice_address_city || "",
|
||||
country: template.invoice_address_country || "Deutschland",
|
||||
};
|
||||
|
||||
const deliveryAddress = {
|
||||
firstName: template.shipping_address_name ? template.shipping_address_name.split(' ')[0] || "" : "",
|
||||
lastName: template.shipping_address_name ? template.shipping_address_name.split(' ').slice(1).join(' ') || "" : "",
|
||||
addressAddition: template.shipping_address_line2 || "",
|
||||
street: template.shipping_address_street || "",
|
||||
houseNumber: template.shipping_address_house_number || "",
|
||||
postalCode: template.shipping_address_postal_code || "",
|
||||
city: template.shipping_address_city || "",
|
||||
country: template.shipping_address_country || "Deutschland",
|
||||
};
|
||||
|
||||
// Get current cart state to check constraints
|
||||
const currentCartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(currentCartItems);
|
||||
|
||||
// Determine delivery method - respect cart constraints
|
||||
let prefillDeliveryMethod = template.delivery_method || "DHL";
|
||||
if (isPickupOnly || hasStecklinge) {
|
||||
prefillDeliveryMethod = "Abholung";
|
||||
}
|
||||
|
||||
// Map delivery method values if needed
|
||||
const deliveryMethodMap = {
|
||||
"standard": "DHL",
|
||||
"express": "DPD",
|
||||
"pickup": "Abholung"
|
||||
};
|
||||
prefillDeliveryMethod = deliveryMethodMap[prefillDeliveryMethod] || prefillDeliveryMethod;
|
||||
|
||||
// Determine payment method - respect constraints
|
||||
let prefillPaymentMethod = template.payment_method || "wire";
|
||||
const paymentMethodMap = {
|
||||
"credit_card": "stripe",
|
||||
"bank_transfer": "wire",
|
||||
"cash_on_delivery": "onDelivery",
|
||||
"cash": "cash"
|
||||
};
|
||||
prefillPaymentMethod = paymentMethodMap[prefillPaymentMethod] || prefillPaymentMethod;
|
||||
|
||||
// Validate payment method against delivery method constraints
|
||||
prefillPaymentMethod = CheckoutValidation.validatePaymentMethodForDelivery(
|
||||
prefillDeliveryMethod,
|
||||
prefillPaymentMethod,
|
||||
currentCartItems,
|
||||
0 // Use 0 for delivery cost during prefill
|
||||
);
|
||||
|
||||
// Apply prefill data to state
|
||||
this.setState({
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
deliveryMethod: prefillDeliveryMethod,
|
||||
paymentMethod: prefillPaymentMethod,
|
||||
saveAddressForFuture: template.save_address_for_future === 1,
|
||||
useSameAddress: true // Default to same address, user can change if needed
|
||||
});
|
||||
|
||||
console.log("Order template applied successfully");
|
||||
} else {
|
||||
console.log("No order template available or failed to fetch");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// Handle payment completion if detected
|
||||
if (this.props.paymentCompletion) {
|
||||
this.orderService.handlePaymentCompletion(
|
||||
this.props.paymentCompletion,
|
||||
this.props.onClearPaymentCompletion
|
||||
);
|
||||
}
|
||||
|
||||
// @note Fetch order template for prefill when component mounts
|
||||
this.fetchOrderTemplate();
|
||||
|
||||
this.cart = () => {
|
||||
// @note Don't update cart if we're showing payment confirmation - keep it empty
|
||||
if (this.state.showPaymentConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cartItems = Array.isArray(window.cart) ? window.cart : [];
|
||||
const shouldForcePickup = CheckoutValidation.shouldForcePickupDelivery(cartItems);
|
||||
|
||||
const newDeliveryMethod = shouldForcePickup ? "Abholung" : this.state.deliveryMethod;
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
|
||||
// Get optimal payment method for the current state
|
||||
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(
|
||||
newDeliveryMethod,
|
||||
cartItems,
|
||||
deliveryCost
|
||||
);
|
||||
|
||||
// Use optimal payment method if current one is invalid, otherwise keep current
|
||||
const validatedPaymentMethod = CheckoutValidation.validatePaymentMethodForDelivery(
|
||||
newDeliveryMethod,
|
||||
this.state.paymentMethod,
|
||||
cartItems,
|
||||
deliveryCost
|
||||
);
|
||||
|
||||
const newPaymentMethod = validatedPaymentMethod !== this.state.paymentMethod
|
||||
? optimalPaymentMethod
|
||||
: this.state.paymentMethod;
|
||||
|
||||
this.setState({
|
||||
cartItems,
|
||||
deliveryMethod: newDeliveryMethod,
|
||||
paymentMethod: newPaymentMethod,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("cart", this.cart);
|
||||
this.cart(); // Initial check
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("cart", this.cart);
|
||||
this.orderService.cleanup();
|
||||
}
|
||||
|
||||
handleCheckout = () => {
|
||||
this.setState({ isCheckingOut: true });
|
||||
};
|
||||
|
||||
handleContinueShopping = () => {
|
||||
this.setState({ isCheckingOut: false });
|
||||
};
|
||||
|
||||
handleDeliveryMethodChange = (event) => {
|
||||
const newDeliveryMethod = event.target.value;
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
|
||||
// Get optimal payment method for the new delivery method
|
||||
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(
|
||||
newDeliveryMethod,
|
||||
this.state.cartItems,
|
||||
deliveryCost
|
||||
);
|
||||
|
||||
// Use optimal payment method if current one becomes invalid, otherwise keep current
|
||||
const validatedPaymentMethod = CheckoutValidation.validatePaymentMethodForDelivery(
|
||||
newDeliveryMethod,
|
||||
this.state.paymentMethod,
|
||||
this.state.cartItems,
|
||||
deliveryCost
|
||||
);
|
||||
|
||||
const newPaymentMethod = validatedPaymentMethod !== this.state.paymentMethod
|
||||
? optimalPaymentMethod
|
||||
: this.state.paymentMethod;
|
||||
|
||||
this.setState({
|
||||
deliveryMethod: newDeliveryMethod,
|
||||
paymentMethod: newPaymentMethod,
|
||||
});
|
||||
};
|
||||
|
||||
handlePaymentMethodChange = (event) => {
|
||||
this.setState({ paymentMethod: event.target.value });
|
||||
};
|
||||
|
||||
handleInvoiceAddressChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
this.setState((prevState) => ({
|
||||
invoiceAddress: {
|
||||
...prevState.invoiceAddress,
|
||||
[name]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
handleDeliveryAddressChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
this.setState((prevState) => ({
|
||||
deliveryAddress: {
|
||||
...prevState.deliveryAddress,
|
||||
[name]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
handleUseSameAddressChange = (e) => {
|
||||
const useSameAddress = e.target.checked;
|
||||
this.setState({
|
||||
useSameAddress,
|
||||
deliveryAddress: useSameAddress
|
||||
? this.state.invoiceAddress
|
||||
: this.state.deliveryAddress,
|
||||
});
|
||||
};
|
||||
|
||||
handleTermsAcceptedChange = (e) => {
|
||||
this.setState({ termsAccepted: e.target.checked });
|
||||
};
|
||||
|
||||
handleNoteChange = (e) => {
|
||||
this.setState({ note: e.target.value });
|
||||
};
|
||||
|
||||
handleSaveAddressForFutureChange = (e) => {
|
||||
this.setState({ saveAddressForFuture: e.target.checked });
|
||||
};
|
||||
|
||||
validateAddressForm = () => {
|
||||
const errors = CheckoutValidation.validateAddressForm(this.state);
|
||||
this.setState({ addressFormErrors: errors });
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
loadStripeComponent = async (clientSecret) => {
|
||||
this.setState({ isLoadingStripe: true });
|
||||
|
||||
try {
|
||||
const { default: Stripe } = await import("../Stripe.js");
|
||||
this.setState({
|
||||
StripeComponent: Stripe,
|
||||
stripeClientSecret: clientSecret,
|
||||
showStripePayment: true,
|
||||
isCompletingOrder: false,
|
||||
isLoadingStripe: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load Stripe component:", error);
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
isLoadingStripe: false,
|
||||
completionError: "Failed to load payment component. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleCompleteOrder = () => {
|
||||
this.setState({ completionError: null }); // Clear previous errors
|
||||
|
||||
const validationError = CheckoutValidation.getValidationErrorMessage(this.state);
|
||||
if (validationError) {
|
||||
this.setState({ completionError: validationError });
|
||||
this.validateAddressForm(); // To show field-specific errors
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isCompletingOrder: true });
|
||||
|
||||
const {
|
||||
deliveryMethod,
|
||||
paymentMethod,
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
cartItems,
|
||||
note,
|
||||
saveAddressForFuture,
|
||||
} = this.state;
|
||||
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
|
||||
// Handle Stripe payment differently
|
||||
if (paymentMethod === "stripe") {
|
||||
// Store the cart items used for Stripe payment in sessionStorage for later reference
|
||||
try {
|
||||
sessionStorage.setItem('stripePaymentCart', JSON.stringify(cartItems));
|
||||
} catch (error) {
|
||||
console.error("Failed to store Stripe payment cart:", error);
|
||||
}
|
||||
|
||||
// Calculate total amount for Stripe
|
||||
const subtotal = cartItems.reduce(
|
||||
(total, item) => total + item.price * item.quantity,
|
||||
0
|
||||
);
|
||||
const totalAmount = Math.round((subtotal + deliveryCost) * 100); // Convert to cents
|
||||
|
||||
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle regular orders
|
||||
const orderData = {
|
||||
items: cartItems,
|
||||
invoiceAddress,
|
||||
deliveryAddress: useSameAddress ? invoiceAddress : deliveryAddress,
|
||||
deliveryMethod,
|
||||
paymentMethod,
|
||||
deliveryCost,
|
||||
note,
|
||||
domain: window.location.origin,
|
||||
saveAddressForFuture,
|
||||
};
|
||||
|
||||
this.orderService.processRegularOrder(orderData);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
cartItems,
|
||||
deliveryMethod,
|
||||
paymentMethod,
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
saveAddressForFuture,
|
||||
addressFormErrors,
|
||||
termsAccepted,
|
||||
isCompletingOrder,
|
||||
completionError,
|
||||
note,
|
||||
stripeClientSecret,
|
||||
showStripePayment,
|
||||
StripeComponent,
|
||||
isLoadingStripe,
|
||||
showPaymentConfirmation,
|
||||
orderCompleted,
|
||||
} = this.state;
|
||||
|
||||
const deliveryCost = this.orderService.getDeliveryCost();
|
||||
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(cartItems);
|
||||
|
||||
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state);
|
||||
const displayError = completionError || preSubmitError;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Payment Confirmation */}
|
||||
{showPaymentConfirmation && (
|
||||
<PaymentConfirmationDialog
|
||||
paymentCompletionData={this.orderService.paymentCompletionData}
|
||||
isCompletingOrder={isCompletingOrder}
|
||||
completionError={completionError}
|
||||
orderCompleted={orderCompleted}
|
||||
onContinueShopping={() => {
|
||||
this.setState({ showPaymentConfirmation: false });
|
||||
}}
|
||||
onViewOrders={() => {
|
||||
if (this.props.onOrderSuccess) {
|
||||
this.props.onOrderSuccess();
|
||||
}
|
||||
this.setState({ showPaymentConfirmation: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* @note Hide CartDropdown when showing payment confirmation */}
|
||||
{!showPaymentConfirmation && (
|
||||
<CartDropdown
|
||||
cartItems={cartItems}
|
||||
socket={this.context}
|
||||
showDetailedSummary={showStripePayment}
|
||||
deliveryMethod={deliveryMethod}
|
||||
deliveryCost={deliveryCost}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cartItems.length > 0 && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{isLoadingStripe ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body1">
|
||||
Zahlungskomponente wird geladen...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : showStripePayment && StripeComponent ? (
|
||||
<>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => this.setState({ showStripePayment: false, stripeClientSecret: null })}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
borderColor: '#2e7d32',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(46, 125, 50, 0.04)',
|
||||
borderColor: '#1b5e20'
|
||||
}
|
||||
}}
|
||||
>
|
||||
← Zurück zur Bestellung
|
||||
</Button>
|
||||
</Box>
|
||||
<StripeComponent clientSecret={stripeClientSecret} />
|
||||
</>
|
||||
) : (
|
||||
<CheckoutForm
|
||||
paymentMethod={paymentMethod}
|
||||
invoiceAddress={invoiceAddress}
|
||||
deliveryAddress={deliveryAddress}
|
||||
useSameAddress={useSameAddress}
|
||||
saveAddressForFuture={saveAddressForFuture}
|
||||
addressFormErrors={addressFormErrors}
|
||||
termsAccepted={termsAccepted}
|
||||
note={note}
|
||||
deliveryMethod={deliveryMethod}
|
||||
hasStecklinge={hasStecklinge}
|
||||
isPickupOnly={isPickupOnly}
|
||||
deliveryCost={deliveryCost}
|
||||
cartItems={cartItems}
|
||||
displayError={displayError}
|
||||
isCompletingOrder={isCompletingOrder}
|
||||
preSubmitError={preSubmitError}
|
||||
onInvoiceAddressChange={this.handleInvoiceAddressChange}
|
||||
onDeliveryAddressChange={this.handleDeliveryAddressChange}
|
||||
onUseSameAddressChange={this.handleUseSameAddressChange}
|
||||
onSaveAddressForFutureChange={this.handleSaveAddressForFutureChange}
|
||||
onTermsAcceptedChange={this.handleTermsAcceptedChange}
|
||||
onNoteChange={this.handleNoteChange}
|
||||
onDeliveryMethodChange={this.handleDeliveryMethodChange}
|
||||
onPaymentMethodChange={this.handlePaymentMethodChange}
|
||||
onCompleteOrder={this.handleCompleteOrder}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set static contextType to access the socket
|
||||
CartTab.contextType = SocketContext;
|
||||
|
||||
export default CartTab;
|
||||
185
src/components/profile/CheckoutForm.js
Normal file
185
src/components/profile/CheckoutForm.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Typography, TextField, Checkbox, FormControlLabel, Button } from "@mui/material";
|
||||
import AddressForm from "./AddressForm.js";
|
||||
import DeliveryMethodSelector from "./DeliveryMethodSelector.js";
|
||||
import PaymentMethodSelector from "./PaymentMethodSelector.js";
|
||||
import OrderSummary from "./OrderSummary.js";
|
||||
|
||||
class CheckoutForm extends Component {
|
||||
render() {
|
||||
const {
|
||||
paymentMethod,
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
saveAddressForFuture,
|
||||
addressFormErrors,
|
||||
termsAccepted,
|
||||
note,
|
||||
deliveryMethod,
|
||||
hasStecklinge,
|
||||
isPickupOnly,
|
||||
deliveryCost,
|
||||
cartItems,
|
||||
displayError,
|
||||
isCompletingOrder,
|
||||
preSubmitError,
|
||||
onInvoiceAddressChange,
|
||||
onDeliveryAddressChange,
|
||||
onUseSameAddressChange,
|
||||
onSaveAddressForFutureChange,
|
||||
onTermsAcceptedChange,
|
||||
onNoteChange,
|
||||
onDeliveryMethodChange,
|
||||
onPaymentMethodChange,
|
||||
onCompleteOrder,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{paymentMethod !== "cash" && (
|
||||
<>
|
||||
<AddressForm
|
||||
title="Rechnungsadresse"
|
||||
address={invoiceAddress}
|
||||
onChange={onInvoiceAddressChange}
|
||||
errors={addressFormErrors}
|
||||
namePrefix="invoice"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={saveAddressForFuture}
|
||||
onChange={onSaveAddressForFutureChange}
|
||||
sx={{ '& .MuiSvgIcon-root': { fontSize: 28 } }}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
Für zukünftige Bestellungen speichern
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasStecklinge && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ mb: 2, fontWeight: "bold", color: "#2e7d32" }}
|
||||
>
|
||||
Für welchen Termin ist die Abholung der Stecklinge
|
||||
gewünscht?
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Anmerkung"
|
||||
name="note"
|
||||
value={note}
|
||||
onChange={onNoteChange}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
sx={{ mb: 2 }}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
|
||||
<DeliveryMethodSelector
|
||||
deliveryMethod={deliveryMethod}
|
||||
onChange={onDeliveryMethodChange}
|
||||
isPickupOnly={isPickupOnly || hasStecklinge}
|
||||
/>
|
||||
|
||||
{(deliveryMethod === "DHL" || deliveryMethod === "DPD") && (
|
||||
<>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={useSameAddress}
|
||||
onChange={onUseSameAddressChange}
|
||||
sx={{ '& .MuiSvgIcon-root': { fontSize: 28 } }}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body1">
|
||||
Lieferadresse ist identisch mit Rechnungsadresse
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{!useSameAddress && (
|
||||
<AddressForm
|
||||
title="Lieferadresse"
|
||||
address={deliveryAddress}
|
||||
onChange={onDeliveryAddressChange}
|
||||
errors={addressFormErrors}
|
||||
namePrefix="delivery"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<PaymentMethodSelector
|
||||
paymentMethod={paymentMethod}
|
||||
onChange={onPaymentMethodChange}
|
||||
deliveryMethod={deliveryMethod}
|
||||
onDeliveryMethodChange={onDeliveryMethodChange}
|
||||
cartItems={cartItems}
|
||||
deliveryCost={deliveryCost}
|
||||
/>
|
||||
|
||||
<OrderSummary deliveryCost={deliveryCost} cartItems={cartItems} />
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={termsAccepted}
|
||||
onChange={onTermsAcceptedChange}
|
||||
sx={{
|
||||
'& .MuiSvgIcon-root': { fontSize: 28 },
|
||||
alignSelf: 'flex-start',
|
||||
mt: -0.5
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
Ich habe die AGBs, die Datenschutzerklärung und die
|
||||
Bestimmungen zum Widerrufsrecht gelesen
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mb: 3, mt: 2 }}
|
||||
/>
|
||||
|
||||
{/* @note Reserve space for error message to prevent layout shift */}
|
||||
<Box sx={{ minHeight: '24px', mb: 2, textAlign: "center" }}>
|
||||
{displayError && (
|
||||
<Typography color="error" sx={{ lineHeight: '24px' }}>
|
||||
{displayError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
sx={{ bgcolor: "#2e7d32", "&:hover": { bgcolor: "#1b5e20" } }}
|
||||
onClick={onCompleteOrder}
|
||||
disabled={isCompletingOrder || !!preSubmitError}
|
||||
>
|
||||
{isCompletingOrder
|
||||
? "Bestellung wird verarbeitet..."
|
||||
: "Bestellung abschließen"}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CheckoutForm;
|
||||
150
src/components/profile/CheckoutValidation.js
Normal file
150
src/components/profile/CheckoutValidation.js
Normal file
@@ -0,0 +1,150 @@
|
||||
class CheckoutValidation {
|
||||
static validateAddressForm(state) {
|
||||
const {
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
deliveryMethod,
|
||||
paymentMethod,
|
||||
} = state;
|
||||
const errors = {};
|
||||
|
||||
// Validate invoice address (skip if payment method is "cash")
|
||||
if (paymentMethod !== "cash") {
|
||||
if (!invoiceAddress.firstName)
|
||||
errors.invoiceFirstName = "Vorname erforderlich";
|
||||
if (!invoiceAddress.lastName)
|
||||
errors.invoiceLastName = "Nachname erforderlich";
|
||||
if (!invoiceAddress.street) errors.invoiceStreet = "Straße erforderlich";
|
||||
if (!invoiceAddress.houseNumber)
|
||||
errors.invoiceHouseNumber = "Hausnummer erforderlich";
|
||||
if (!invoiceAddress.postalCode)
|
||||
errors.invoicePostalCode = "PLZ erforderlich";
|
||||
if (!invoiceAddress.city) errors.invoiceCity = "Stadt erforderlich";
|
||||
}
|
||||
|
||||
// Validate delivery address for shipping methods that require it
|
||||
if (
|
||||
!useSameAddress &&
|
||||
(deliveryMethod === "DHL" || deliveryMethod === "DPD")
|
||||
) {
|
||||
if (!deliveryAddress.firstName)
|
||||
errors.deliveryFirstName = "Vorname erforderlich";
|
||||
if (!deliveryAddress.lastName)
|
||||
errors.deliveryLastName = "Nachname erforderlich";
|
||||
if (!deliveryAddress.street)
|
||||
errors.deliveryStreet = "Straße erforderlich";
|
||||
if (!deliveryAddress.houseNumber)
|
||||
errors.deliveryHouseNumber = "Hausnummer erforderlich";
|
||||
if (!deliveryAddress.postalCode)
|
||||
errors.deliveryPostalCode = "PLZ erforderlich";
|
||||
if (!deliveryAddress.city) errors.deliveryCity = "Stadt erforderlich";
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
static getValidationErrorMessage(state, isAddressOnly = false) {
|
||||
const { termsAccepted } = state;
|
||||
|
||||
const addressErrors = this.validateAddressForm(state);
|
||||
|
||||
if (isAddressOnly) {
|
||||
return addressErrors;
|
||||
}
|
||||
|
||||
if (Object.keys(addressErrors).length > 0) {
|
||||
return "Bitte überprüfen Sie Ihre Eingaben in den Adressfeldern.";
|
||||
}
|
||||
|
||||
// Validate terms acceptance
|
||||
if (!termsAccepted) {
|
||||
return "Bitte akzeptieren Sie die AGBs, Datenschutzerklärung und Widerrufsrecht, um fortzufahren.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static getOptimalPaymentMethod(deliveryMethod, cartItems = [], deliveryCost = 0) {
|
||||
// Calculate total amount
|
||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||
const totalAmount = subtotal + deliveryCost;
|
||||
|
||||
// If total is 0, only cash is allowed
|
||||
if (totalAmount === 0) {
|
||||
return "cash";
|
||||
}
|
||||
|
||||
// If total is less than 0.50€, stripe is not available
|
||||
if (totalAmount < 0.50) {
|
||||
return "wire";
|
||||
}
|
||||
|
||||
// Prefer stripe when available and meets minimum amount
|
||||
if (deliveryMethod === "DHL" || deliveryMethod === "DPD" || deliveryMethod === "Abholung") {
|
||||
return "stripe";
|
||||
}
|
||||
|
||||
// Fall back to wire transfer
|
||||
return "wire";
|
||||
}
|
||||
|
||||
static validatePaymentMethodForDelivery(deliveryMethod, paymentMethod, cartItems = [], deliveryCost = 0) {
|
||||
let newPaymentMethod = paymentMethod;
|
||||
|
||||
// Calculate total amount for minimum validation
|
||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||
const totalAmount = subtotal + deliveryCost;
|
||||
|
||||
// Reset payment method if it's no longer valid
|
||||
if (deliveryMethod !== "DHL" && paymentMethod === "onDelivery") {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
// Allow stripe for DHL, DPD, and Abholung delivery methods, but check minimum amount
|
||||
if (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung" && paymentMethod === "stripe") {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
// Check minimum amount for stripe payments
|
||||
if (paymentMethod === "stripe" && totalAmount < 0.50) {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
if (deliveryMethod !== "Abholung" && paymentMethod === "cash") {
|
||||
newPaymentMethod = "wire";
|
||||
}
|
||||
|
||||
return newPaymentMethod;
|
||||
}
|
||||
|
||||
static shouldForcePickupDelivery(cartItems) {
|
||||
const isPickupOnly = cartItems.some(
|
||||
(item) => item.versandklasse === "nur Abholung"
|
||||
);
|
||||
const hasStecklinge = cartItems.some(
|
||||
(item) =>
|
||||
item.id &&
|
||||
typeof item.id === "string" &&
|
||||
item.id.endsWith("steckling")
|
||||
);
|
||||
|
||||
return isPickupOnly || hasStecklinge;
|
||||
}
|
||||
|
||||
static getCartItemFlags(cartItems) {
|
||||
const isPickupOnly = cartItems.some(
|
||||
(item) => item.versandklasse === "nur Abholung"
|
||||
);
|
||||
const hasStecklinge = cartItems.some(
|
||||
(item) =>
|
||||
item.id &&
|
||||
typeof item.id === "string" &&
|
||||
item.id.endsWith("steckling")
|
||||
);
|
||||
|
||||
return { isPickupOnly, hasStecklinge };
|
||||
}
|
||||
}
|
||||
|
||||
export default CheckoutValidation;
|
||||
122
src/components/profile/DeliveryMethodSelector.js
Normal file
122
src/components/profile/DeliveryMethodSelector.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
|
||||
const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly }) => {
|
||||
const deliveryOptions = [
|
||||
{
|
||||
id: 'DHL',
|
||||
name: 'DHL',
|
||||
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
|
||||
price: '6,99 €',
|
||||
disabled: isPickupOnly
|
||||
},
|
||||
{
|
||||
id: 'DPD',
|
||||
name: 'DPD',
|
||||
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
|
||||
price: '4,90 €',
|
||||
disabled: isPickupOnly
|
||||
},
|
||||
{
|
||||
id: 'Sperrgut',
|
||||
name: 'Sperrgut',
|
||||
description: 'Für große und schwere Artikel',
|
||||
price: '28,99 €',
|
||||
disabled: true,
|
||||
isCheckbox: true
|
||||
},
|
||||
{
|
||||
id: 'Abholung',
|
||||
name: 'Abholung in der Filiale',
|
||||
description: '',
|
||||
price: ''
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Versandart wählen
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{deliveryOptions.map((option, index) => (
|
||||
<Box
|
||||
key={option.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: index < deliveryOptions.length - 1 ? 1 : 0,
|
||||
p: 1,
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 1,
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
backgroundColor: option.disabled ? '#f5f5f5' : 'transparent',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': !option.disabled ? {
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderColor: '#2e7d32',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
} : {},
|
||||
...(deliveryMethod === option.id && !option.disabled && {
|
||||
backgroundColor: '#e8f5e8',
|
||||
borderColor: '#2e7d32'
|
||||
})
|
||||
}}
|
||||
onClick={!option.disabled && !option.isCheckbox ? () => onChange({ target: { value: option.id } }) : undefined}
|
||||
>
|
||||
{option.isCheckbox ? (
|
||||
<Checkbox
|
||||
id={option.id}
|
||||
disabled={option.disabled}
|
||||
checked={false}
|
||||
sx={{ color: 'rgba(0, 0, 0, 0.54)' }}
|
||||
/>
|
||||
) : (
|
||||
<Radio
|
||||
id={option.id}
|
||||
name="deliveryMethod"
|
||||
value={option.id}
|
||||
checked={deliveryMethod === option.id}
|
||||
onChange={onChange}
|
||||
disabled={option.disabled}
|
||||
sx={{ cursor: option.disabled ? 'not-allowed' : 'pointer' }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ ml: 2, flexGrow: 1 }}>
|
||||
<label
|
||||
htmlFor={option.id}
|
||||
style={{
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
color: option.disabled ? 'rgba(0, 0, 0, 0.54)' : 'inherit'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ color: 'inherit' }}>
|
||||
{option.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ color: 'inherit' }}
|
||||
>
|
||||
{option.description}
|
||||
</Typography>
|
||||
</label>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: option.disabled ? 'rgba(0, 0, 0, 0.54)' : 'inherit' }}
|
||||
>
|
||||
{option.price}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeliveryMethodSelector;
|
||||
171
src/components/profile/OrderDetailsDialog.js
Normal file
171
src/components/profile/OrderDetailsDialog.js
Normal file
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
|
||||
const OrderDetailsDialog = ({ open, onClose, order }) => {
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||
|
||||
const handleCancelOrder = () => {
|
||||
// Implement order cancellation logic here
|
||||
console.log(`Cancel order: ${order.orderId}`);
|
||||
onClose(); // Close the dialog after action
|
||||
};
|
||||
|
||||
const subtotal = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
|
||||
const total = subtotal + order.delivery_cost;
|
||||
|
||||
// Calculate VAT breakdown similar to CartDropdown
|
||||
const vatCalculations = order.items.reduce((acc, item) => {
|
||||
const totalItemPrice = item.price * item.quantity_ordered;
|
||||
const netPrice = totalItemPrice / (1 + item.vat / 100);
|
||||
const vatAmount = totalItemPrice - netPrice;
|
||||
|
||||
acc.totalGross += totalItemPrice;
|
||||
acc.totalNet += netPrice;
|
||||
|
||||
if (item.vat === 7) {
|
||||
acc.vat7 += vatAmount;
|
||||
} else if (item.vat === 19) {
|
||||
acc.vat19 += vatAmount;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Bestelldetails: {order.orderId}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h6">Lieferadresse</Typography>
|
||||
<Typography>{order.shipping_address_name}</Typography>
|
||||
<Typography>{order.shipping_address_street} {order.shipping_address_house_number}</Typography>
|
||||
<Typography>{order.shipping_address_postal_code} {order.shipping_address_city}</Typography>
|
||||
<Typography>{order.shipping_address_country}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h6">Rechnungsadresse</Typography>
|
||||
<Typography>{order.invoice_address_name}</Typography>
|
||||
<Typography>{order.invoice_address_street} {order.invoice_address_house_number}</Typography>
|
||||
<Typography>{order.invoice_address_postal_code} {order.invoice_address_city}</Typography>
|
||||
<Typography>{order.invoice_address_country}</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Order Details Section */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Bestelldetails</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 4 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">Lieferart:</Typography>
|
||||
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || 'Nicht angegeben'}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">Zahlungsart:</Typography>
|
||||
<Typography variant="body1">{order.paymentMethod || order.payment_method || 'Nicht angegeben'}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" gutterBottom>Bestellte Artikel</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Artikel</TableCell>
|
||||
<TableCell align="right">Menge</TableCell>
|
||||
<TableCell align="right">Preis</TableCell>
|
||||
<TableCell align="right">Gesamt</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{order.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell align="right">{item.quantity_ordered}</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(item.price)}</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(item.price * item.quantity_ordered)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">Gesamtnettopreis</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">{currencyFormatter.format(vatCalculations.totalNet)}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{vatCalculations.vat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">7% Mehrwertsteuer</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat7)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{vatCalculations.vat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">19% Mehrwertsteuer</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat19)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">Zwischensumme</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">{currencyFormatter.format(subtotal)}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">Lieferkosten</TableCell>
|
||||
<TableCell align="right">{currencyFormatter.format(order.delivery_cost)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">Gesamtsumme</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">{currencyFormatter.format(total)}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{order.status === 'new' && (
|
||||
<Button onClick={handleCancelOrder} color="error">
|
||||
Bestellung stornieren
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Schließen</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderDetailsDialog;
|
||||
315
src/components/profile/OrderProcessingService.js
Normal file
315
src/components/profile/OrderProcessingService.js
Normal file
@@ -0,0 +1,315 @@
|
||||
import { isUserLoggedIn } from "../LoginComponent.js";
|
||||
|
||||
class OrderProcessingService {
|
||||
constructor(getContext, setState) {
|
||||
this.getContext = getContext;
|
||||
this.setState = setState;
|
||||
this.verifyTokenHandler = null;
|
||||
this.verifyTokenTimeout = null;
|
||||
this.socketHandler = null;
|
||||
this.paymentCompletionData = null;
|
||||
}
|
||||
|
||||
// Clean up all event listeners and timeouts
|
||||
cleanup() {
|
||||
if (this.verifyTokenHandler) {
|
||||
window.removeEventListener('cart', this.verifyTokenHandler);
|
||||
this.verifyTokenHandler = null;
|
||||
}
|
||||
if (this.verifyTokenTimeout) {
|
||||
clearTimeout(this.verifyTokenTimeout);
|
||||
this.verifyTokenTimeout = null;
|
||||
}
|
||||
if (this.socketHandler) {
|
||||
window.removeEventListener('cart', this.socketHandler);
|
||||
this.socketHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle payment completion from parent component
|
||||
handlePaymentCompletion(paymentCompletion, onClearPaymentCompletion) {
|
||||
// Store payment completion data before clearing
|
||||
this.paymentCompletionData = { ...paymentCompletion };
|
||||
|
||||
// Clear payment completion data to prevent duplicates
|
||||
if (onClearPaymentCompletion) {
|
||||
onClearPaymentCompletion();
|
||||
}
|
||||
|
||||
// Show payment confirmation immediately but wait for verifyToken to complete
|
||||
this.setState({
|
||||
showPaymentConfirmation: true,
|
||||
cartItems: [] // Clear UI cart immediately
|
||||
});
|
||||
|
||||
// Wait for verifyToken to complete and populate window.cart, then process order
|
||||
this.waitForVerifyTokenAndProcessOrder();
|
||||
}
|
||||
|
||||
waitForVerifyTokenAndProcessOrder() {
|
||||
// Check if window.cart is already populated (verifyToken already completed)
|
||||
if (Array.isArray(window.cart) && window.cart.length > 0) {
|
||||
this.processStripeOrderWithCart(window.cart);
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for cart event which is dispatched after verifyToken completes
|
||||
this.verifyTokenHandler = () => {
|
||||
if (Array.isArray(window.cart) && window.cart.length > 0) {
|
||||
this.processStripeOrderWithCart([...window.cart]); // Copy the cart
|
||||
|
||||
// Clear window.cart after copying
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
} else {
|
||||
this.setState({
|
||||
completionError: "Cart is empty. Please add items to your cart before placing an order."
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up listener
|
||||
if (this.verifyTokenHandler) {
|
||||
window.removeEventListener('cart', this.verifyTokenHandler);
|
||||
this.verifyTokenHandler = null;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('cart', this.verifyTokenHandler);
|
||||
|
||||
// Set up a timeout as fallback (in case verifyToken fails)
|
||||
this.verifyTokenTimeout = setTimeout(() => {
|
||||
if (Array.isArray(window.cart) && window.cart.length > 0) {
|
||||
this.processStripeOrderWithCart([...window.cart]);
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
} else {
|
||||
this.setState({
|
||||
completionError: "Unable to load cart data. Please refresh the page and try again."
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (this.verifyTokenHandler) {
|
||||
window.removeEventListener('cart', this.verifyTokenHandler);
|
||||
this.verifyTokenHandler = null;
|
||||
}
|
||||
}, 5000); // 5 second timeout
|
||||
}
|
||||
|
||||
processStripeOrderWithCart(cartItems) {
|
||||
// Clear timeout if it exists
|
||||
if (this.verifyTokenTimeout) {
|
||||
clearTimeout(this.verifyTokenTimeout);
|
||||
this.verifyTokenTimeout = null;
|
||||
}
|
||||
|
||||
// Store cart items in state and process order
|
||||
this.setState({
|
||||
originalCartItems: cartItems
|
||||
}, () => {
|
||||
this.processStripeOrder();
|
||||
});
|
||||
}
|
||||
|
||||
processStripeOrder() {
|
||||
// If no original cart items, don't process
|
||||
if (!this.getState().originalCartItems || this.getState().originalCartItems.length === 0) {
|
||||
this.setState({ completionError: "Cart is empty. Please add items to your cart before placing an order." });
|
||||
return;
|
||||
}
|
||||
|
||||
// If socket is ready, process immediately
|
||||
const context = this.getContext();
|
||||
if (context && context.connected) {
|
||||
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
|
||||
if (isAuthenticated) {
|
||||
this.sendStripeOrder();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for socket to be ready
|
||||
this.socketHandler = () => {
|
||||
const context = this.getContext();
|
||||
if (context && context.connected) {
|
||||
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
|
||||
const state = this.getState();
|
||||
|
||||
if (isAuthenticated && state.showPaymentConfirmation && !state.isCompletingOrder) {
|
||||
this.sendStripeOrder();
|
||||
}
|
||||
}
|
||||
// Clean up
|
||||
if (this.socketHandler) {
|
||||
window.removeEventListener('cart', this.socketHandler);
|
||||
this.socketHandler = null;
|
||||
}
|
||||
};
|
||||
window.addEventListener('cart', this.socketHandler);
|
||||
}
|
||||
|
||||
sendStripeOrder() {
|
||||
const state = this.getState();
|
||||
|
||||
// Don't process if already processing or completed
|
||||
if (state.isCompletingOrder || state.orderCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isCompletingOrder: true, completionError: null });
|
||||
|
||||
const {
|
||||
deliveryMethod,
|
||||
invoiceAddress,
|
||||
deliveryAddress,
|
||||
useSameAddress,
|
||||
originalCartItems,
|
||||
note,
|
||||
saveAddressForFuture,
|
||||
} = state;
|
||||
|
||||
const deliveryCost = this.getDeliveryCost();
|
||||
|
||||
const orderData = {
|
||||
items: originalCartItems,
|
||||
invoiceAddress,
|
||||
deliveryAddress: useSameAddress ? invoiceAddress : deliveryAddress,
|
||||
deliveryMethod,
|
||||
paymentMethod: "stripe",
|
||||
deliveryCost,
|
||||
note,
|
||||
domain: window.location.origin,
|
||||
stripeData: this.paymentCompletionData ? {
|
||||
paymentIntent: this.paymentCompletionData.paymentIntent,
|
||||
paymentIntentClientSecret: this.paymentCompletionData.paymentIntentClientSecret,
|
||||
redirectStatus: this.paymentCompletionData.redirectStatus,
|
||||
} : null,
|
||||
saveAddressForFuture,
|
||||
};
|
||||
|
||||
// Emit stripe order to backend via socket.io
|
||||
const context = this.getContext();
|
||||
context.emit("issueStripeOrder", orderData, (response) => {
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
orderCompleted: true,
|
||||
completionError: null,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: response.error || "Failed to complete order. Please try again.",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process regular (non-Stripe) orders
|
||||
processRegularOrder(orderData) {
|
||||
const context = this.getContext();
|
||||
if (context) {
|
||||
context.emit("issueOrder", orderData, (response) => {
|
||||
if (response.success) {
|
||||
// Clear the cart
|
||||
window.cart = [];
|
||||
window.dispatchEvent(new CustomEvent("cart"));
|
||||
|
||||
// Reset state and navigate to orders tab
|
||||
this.setState({
|
||||
isCheckingOut: false,
|
||||
cartItems: [],
|
||||
isCompletingOrder: false,
|
||||
completionError: null,
|
||||
});
|
||||
|
||||
// Call success callback if provided
|
||||
if (this.onOrderSuccess) {
|
||||
this.onOrderSuccess();
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: response.error || "Failed to complete order. Please try again.",
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error("Socket context not available");
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: "Cannot connect to server. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create Stripe payment intent
|
||||
createStripeIntent(totalAmount, loadStripeComponent) {
|
||||
const context = this.getContext();
|
||||
if (context) {
|
||||
context.emit(
|
||||
"createStripeIntent",
|
||||
{ amount: totalAmount },
|
||||
(response) => {
|
||||
if (response.success) {
|
||||
loadStripeComponent(response.client_secret);
|
||||
} else {
|
||||
console.error("Error:", response.error);
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: response.error || "Failed to create Stripe payment intent. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.error("Socket context not available");
|
||||
this.setState({
|
||||
isCompletingOrder: false,
|
||||
completionError: "Cannot connect to server. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate delivery cost
|
||||
getDeliveryCost() {
|
||||
const { deliveryMethod, paymentMethod } = this.getState();
|
||||
let cost = 0;
|
||||
|
||||
switch (deliveryMethod) {
|
||||
case "DHL":
|
||||
cost = 6.99;
|
||||
break;
|
||||
case "DPD":
|
||||
cost = 4.9;
|
||||
break;
|
||||
case "Sperrgut":
|
||||
cost = 28.99;
|
||||
break;
|
||||
case "Abholung":
|
||||
cost = 0;
|
||||
break;
|
||||
default:
|
||||
cost = 6.99;
|
||||
}
|
||||
|
||||
// Add onDelivery surcharge if selected
|
||||
if (paymentMethod === "onDelivery") {
|
||||
cost += 8.99;
|
||||
}
|
||||
|
||||
return cost;
|
||||
}
|
||||
|
||||
// Helper method to get current state (to be overridden by component)
|
||||
getState() {
|
||||
throw new Error("getState method must be implemented by the component");
|
||||
}
|
||||
|
||||
// Set callback for order success
|
||||
setOrderSuccessCallback(callback) {
|
||||
this.onOrderSuccess = callback;
|
||||
}
|
||||
}
|
||||
|
||||
export default OrderProcessingService;
|
||||
106
src/components/profile/OrderSummary.js
Normal file
106
src/components/profile/OrderSummary.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
|
||||
const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
|
||||
const currencyFormatter = new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
});
|
||||
|
||||
// Calculate VAT breakdown for cart items (similar to CartDropdown)
|
||||
const cartVatCalculations = cartItems.reduce((acc, item) => {
|
||||
const totalItemPrice = item.price * item.quantity;
|
||||
const netPrice = totalItemPrice / (1 + item.vat / 100);
|
||||
const vatAmount = totalItemPrice - netPrice;
|
||||
|
||||
acc.totalGross += totalItemPrice;
|
||||
acc.totalNet += netPrice;
|
||||
|
||||
if (item.vat === 7) {
|
||||
acc.vat7 += vatAmount;
|
||||
} else if (item.vat === 19) {
|
||||
acc.vat19 += vatAmount;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
|
||||
|
||||
// Calculate shipping VAT (19% VAT for shipping costs)
|
||||
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
|
||||
const shippingVat = deliveryCost - shippingNetPrice;
|
||||
|
||||
// Combine totals - add shipping VAT to the 19% VAT total
|
||||
const totalVat7 = cartVatCalculations.vat7;
|
||||
const totalVat19 = cartVatCalculations.vat19 + shippingVat;
|
||||
const totalGross = cartVatCalculations.totalGross + deliveryCost;
|
||||
|
||||
return (
|
||||
<Box sx={{ my: 3, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Bestellübersicht
|
||||
</Typography>
|
||||
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Waren (netto):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(cartVatCalculations.totalNet)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>Versandkosten (netto):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(shippingNetPrice)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{totalVat7 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>7% Mehrwertsteuer:</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat7)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{totalVat19 > 0 && (
|
||||
<TableRow>
|
||||
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(totalVat19)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{currencyFormatter.format(cartVatCalculations.totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{deliveryCost > 0 && (
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Versandkosten:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
|
||||
{currencyFormatter.format(deliveryCost)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
|
||||
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>Gesamtsumme:</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
|
||||
{currencyFormatter.format(totalGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderSummary;
|
||||
246
src/components/profile/OrdersTab.js
Normal file
246
src/components/profile/OrdersTab.js
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Alert,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import SocketContext from "../../contexts/SocketContext.js";
|
||||
import OrderDetailsDialog from "./OrderDetailsDialog.js";
|
||||
|
||||
// Constants
|
||||
const statusTranslations = {
|
||||
new: "in Bearbeitung",
|
||||
pending: "Neu",
|
||||
processing: "in Bearbeitung",
|
||||
cancelled: "Storniert",
|
||||
shipped: "Verschickt",
|
||||
delivered: "Geliefert",
|
||||
};
|
||||
|
||||
const statusEmojis = {
|
||||
"in Bearbeitung": "⚙️",
|
||||
pending: "⏳",
|
||||
processing: "🔄",
|
||||
cancelled: "❌",
|
||||
Verschickt: "🚚",
|
||||
Geliefert: "✅",
|
||||
Storniert: "❌",
|
||||
Retoure: "↩️",
|
||||
"Teil Retoure": "↪️",
|
||||
"Teil geliefert": "⚡",
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
"in Bearbeitung": "#ed6c02", // orange
|
||||
pending: "#ff9800", // orange for pending
|
||||
processing: "#2196f3", // blue for processing
|
||||
cancelled: "#d32f2f", // red for cancelled
|
||||
Verschickt: "#2e7d32", // green
|
||||
Geliefert: "#2e7d32", // green
|
||||
Storniert: "#d32f2f", // red
|
||||
Retoure: "#9c27b0", // purple
|
||||
"Teil Retoure": "#9c27b0", // purple
|
||||
"Teil geliefert": "#009688", // teal
|
||||
};
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
});
|
||||
|
||||
// Orders Tab Content Component
|
||||
const OrdersTab = ({ orderIdFromHash }) => {
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
|
||||
|
||||
const socket = useContext(SocketContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleViewDetails = useCallback(
|
||||
(orderId) => {
|
||||
const orderToView = orders.find((order) => order.orderId === orderId);
|
||||
if (orderToView) {
|
||||
setSelectedOrder(orderToView);
|
||||
setIsDetailsDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[orders]
|
||||
);
|
||||
|
||||
const fetchOrders = useCallback(() => {
|
||||
if (socket && socket.connected) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
socket.emit("getOrders", (response) => {
|
||||
if (response.success) {
|
||||
setOrders(response.orders);
|
||||
} else {
|
||||
setError(response.error || "Failed to fetch orders.");
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
// Socket not connected yet, but don't show error immediately on first load
|
||||
console.log("Socket not connected yet, waiting for connection to fetch orders");
|
||||
setLoading(false); // Stop loading when socket is not connected
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
// Monitor socket connection changes
|
||||
useEffect(() => {
|
||||
if (socket && socket.connected && orders.length === 0) {
|
||||
// Socket just connected and we don't have orders yet, fetch them
|
||||
fetchOrders();
|
||||
}
|
||||
}, [socket, socket?.connected, orders.length, fetchOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (orderIdFromHash && orders.length > 0) {
|
||||
handleViewDetails(orderIdFromHash);
|
||||
}
|
||||
}, [orderIdFromHash, orders, handleViewDetails]);
|
||||
|
||||
const getStatusDisplay = (status) => {
|
||||
return statusTranslations[status] || status;
|
||||
};
|
||||
|
||||
const getStatusEmoji = (status) => {
|
||||
return statusEmojis[status] || "❓";
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
return statusColors[status] || "#757575"; // default gray
|
||||
};
|
||||
|
||||
const handleCloseDetailsDialog = () => {
|
||||
setIsDetailsDialogOpen(false);
|
||||
setSelectedOrder(null);
|
||||
navigate("/profile", { replace: true });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ p: 3, display: "flex", justifyContent: "center" }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{orders.length > 0 ? (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bestellnummer</TableCell>
|
||||
<TableCell>Datum</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Artikel</TableCell>
|
||||
<TableCell align="right">Summe</TableCell>
|
||||
<TableCell align="center">Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{orders.map((order) => {
|
||||
const displayStatus = getStatusDisplay(order.status);
|
||||
const subtotal = order.items.reduce(
|
||||
(acc, item) => acc + item.price * item.quantity_ordered,
|
||||
0
|
||||
);
|
||||
const total = subtotal + order.delivery_cost;
|
||||
return (
|
||||
<TableRow key={order.orderId} hover>
|
||||
<TableCell>{order.orderId}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(order.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
color: getStatusColor(displayStatus),
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "1.2rem" }}>
|
||||
{getStatusEmoji(displayStatus)}
|
||||
</span>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="span"
|
||||
sx={{ fontWeight: "medium" }}
|
||||
>
|
||||
{displayStatus}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{order.items.reduce(
|
||||
(acc, item) => acc + item.quantity_ordered,
|
||||
0
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{currencyFormatter.format(total)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="Details anzeigen">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleViewDetails(order.orderId)}
|
||||
>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
Sie haben noch keine Bestellungen aufgegeben.
|
||||
</Alert>
|
||||
)}
|
||||
<OrderDetailsDialog
|
||||
open={isDetailsDialogOpen}
|
||||
onClose={handleCloseDetailsDialog}
|
||||
order={selectedOrder}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrdersTab;
|
||||
97
src/components/profile/PaymentConfirmationDialog.js
Normal file
97
src/components/profile/PaymentConfirmationDialog.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { Component } from "react";
|
||||
import { Box, Typography, Button } from "@mui/material";
|
||||
|
||||
class PaymentConfirmationDialog extends Component {
|
||||
render() {
|
||||
const {
|
||||
paymentCompletionData,
|
||||
isCompletingOrder,
|
||||
completionError,
|
||||
orderCompleted,
|
||||
onContinueShopping,
|
||||
onViewOrders,
|
||||
} = this.props;
|
||||
|
||||
if (!paymentCompletionData) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
mb: 3,
|
||||
p: 3,
|
||||
border: '2px solid',
|
||||
borderColor: paymentCompletionData.isSuccessful ? '#2e7d32' : '#d32f2f',
|
||||
borderRadius: 2,
|
||||
bgcolor: paymentCompletionData.isSuccessful ? '#e8f5e8' : '#ffebee'
|
||||
}}>
|
||||
<Typography variant="h5" sx={{
|
||||
mb: 2,
|
||||
color: paymentCompletionData.isSuccessful ? '#2e7d32' : '#d32f2f',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{paymentCompletionData.isSuccessful ? 'Zahlung erfolgreich!' : 'Zahlung fehlgeschlagen'}
|
||||
</Typography>
|
||||
|
||||
{paymentCompletionData.isSuccessful ? (
|
||||
<>
|
||||
{orderCompleted ? (
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
|
||||
🎉 Ihre Bestellung wurde erfolgreich abgeschlossen! Sie können jetzt Ihre Bestellungen einsehen.
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
|
||||
Ihre Zahlung wurde erfolgreich verarbeitet. Die Bestellung wird automatisch abgeschlossen.
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#d32f2f' }}>
|
||||
Ihre Zahlung konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut oder wählen Sie eine andere Zahlungsmethode.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{isCompletingOrder && (
|
||||
<Typography variant="body2" sx={{ mt: 2, color: '#2e7d32', p: 2, bgcolor: '#e8f5e8', borderRadius: 1 }}>
|
||||
Bestellung wird abgeschlossen...
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{completionError && (
|
||||
<Typography variant="body2" sx={{ mt: 2, color: '#d32f2f', p: 2, bgcolor: '#ffcdd2', borderRadius: 1 }}>
|
||||
{completionError}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{orderCompleted && (
|
||||
<Box sx={{ mt: 3, display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={onContinueShopping}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
borderColor: '#2e7d32',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(46, 125, 50, 0.04)',
|
||||
borderColor: '#1b5e20'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Weiter einkaufen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onViewOrders}
|
||||
variant="contained"
|
||||
sx={{
|
||||
bgcolor: '#2e7d32',
|
||||
'&:hover': { bgcolor: '#1b5e20' }
|
||||
}}
|
||||
>
|
||||
Zu meinen Bestellungen
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PaymentConfirmationDialog;
|
||||
178
src/components/profile/PaymentMethodSelector.js
Normal file
178
src/components/profile/PaymentMethodSelector.js
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useCallback } from "react";
|
||||
import { Box, Typography, Radio } from "@mui/material";
|
||||
|
||||
const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeliveryMethodChange, cartItems = [], deliveryCost = 0 }) => {
|
||||
|
||||
// Calculate total amount
|
||||
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||
const totalAmount = subtotal + deliveryCost;
|
||||
|
||||
// Handle payment method changes with automatic delivery method adjustment
|
||||
const handlePaymentMethodChange = useCallback((event) => {
|
||||
const selectedPaymentMethod = event.target.value;
|
||||
|
||||
// If "Zahlung in der Filiale" is selected, force delivery method to "Abholung"
|
||||
if (selectedPaymentMethod === "cash" && deliveryMethod !== "Abholung") {
|
||||
if (onDeliveryMethodChange) {
|
||||
onDeliveryMethodChange({ target: { value: "Abholung" } });
|
||||
}
|
||||
}
|
||||
|
||||
onChange(event);
|
||||
}, [deliveryMethod, onDeliveryMethodChange, onChange]);
|
||||
|
||||
// Handle delivery method changes - auto-switch to stripe when DHL/DPD is selected
|
||||
useEffect(() => {
|
||||
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
|
||||
handlePaymentMethodChange({ target: { value: "stripe" } });
|
||||
}
|
||||
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
|
||||
|
||||
// Auto-switch to cash when total amount is 0
|
||||
useEffect(() => {
|
||||
if (totalAmount === 0 && paymentMethod !== "cash") {
|
||||
handlePaymentMethodChange({ target: { value: "cash" } });
|
||||
}
|
||||
}, [totalAmount, paymentMethod, handlePaymentMethodChange]);
|
||||
|
||||
const paymentOptions = [
|
||||
{
|
||||
id: "wire",
|
||||
name: "Überweisung",
|
||||
description: "Bezahlen Sie per Banküberweisung",
|
||||
disabled: totalAmount === 0,
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
name: "Karte oder Sofortüberweisung",
|
||||
description: totalAmount < 0.50 && totalAmount > 0
|
||||
? "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)"
|
||||
: "Bezahlen Sie per Karte oder Sofortüberweisung",
|
||||
disabled: totalAmount < 0.50 || (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung"),
|
||||
icons: [
|
||||
"/assets/images/giropay.png",
|
||||
"/assets/images/maestro.png",
|
||||
"/assets/images/mastercard.png",
|
||||
"/assets/images/visa_electron.png",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "onDelivery",
|
||||
name: "Nachnahme",
|
||||
description: "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
|
||||
disabled: totalAmount === 0 || deliveryMethod !== "DHL",
|
||||
icons: ["/assets/images/cash.png"],
|
||||
},
|
||||
{
|
||||
id: "cash",
|
||||
name: "Zahlung in der Filiale",
|
||||
description: "Bei Abholung bezahlen",
|
||||
disabled: false, // Always enabled
|
||||
icons: ["/assets/images/cash.png"],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Zahlungsart wählen
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{paymentOptions.map((option, index) => (
|
||||
<Box
|
||||
key={option.id}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
mb: index < paymentOptions.length - 1 ? 1 : 0,
|
||||
p: 1,
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: 1,
|
||||
cursor: option.disabled ? "not-allowed" : "pointer",
|
||||
opacity: option.disabled ? 0.6 : 1,
|
||||
transition: "all 0.2s ease-in-out",
|
||||
"&:hover": !option.disabled
|
||||
? {
|
||||
backgroundColor: "#f5f5f5",
|
||||
borderColor: "#2e7d32",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
}
|
||||
: {},
|
||||
...(paymentMethod === option.id &&
|
||||
!option.disabled && {
|
||||
backgroundColor: "#e8f5e8",
|
||||
borderColor: "#2e7d32",
|
||||
}),
|
||||
}}
|
||||
onClick={
|
||||
!option.disabled
|
||||
? () => handlePaymentMethodChange({ target: { value: option.id } })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Radio
|
||||
id={option.id}
|
||||
name="paymentMethod"
|
||||
value={option.id}
|
||||
checked={paymentMethod === option.id}
|
||||
onChange={handlePaymentMethodChange}
|
||||
disabled={option.disabled}
|
||||
sx={{ cursor: option.disabled ? "not-allowed" : "pointer" }}
|
||||
/>
|
||||
<Box sx={{ ml: 2, flex: 1 }}>
|
||||
<label
|
||||
htmlFor={option.id}
|
||||
style={{
|
||||
cursor: option.disabled ? "not-allowed" : "pointer",
|
||||
color: option.disabled ? "#999" : "inherit",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: option.disabled ? "#999" : "inherit" }}
|
||||
>
|
||||
{option.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ color: option.disabled ? "#ccc" : "text.secondary" }}
|
||||
>
|
||||
{option.description}
|
||||
</Typography>
|
||||
</label>
|
||||
</Box>
|
||||
{option.icons && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
ml: 2
|
||||
}}
|
||||
>
|
||||
{option.icons.map((iconPath, iconIndex) => (
|
||||
<img
|
||||
key={iconIndex}
|
||||
src={iconPath}
|
||||
alt={`Payment method ${iconIndex + 1}`}
|
||||
style={{
|
||||
height: "24px",
|
||||
width: "auto",
|
||||
opacity: option.disabled ? 0.5 : 1,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethodSelector;
|
||||
426
src/components/profile/SettingsTab.js
Normal file
426
src/components/profile/SettingsTab.js
Normal file
@@ -0,0 +1,426 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
IconButton,
|
||||
Snackbar
|
||||
} from '@mui/material';
|
||||
import { ContentCopy } from '@mui/icons-material';
|
||||
|
||||
class SettingsTab extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
password: '',
|
||||
newEmail: '',
|
||||
passwordError: '',
|
||||
passwordSuccess: '',
|
||||
emailError: '',
|
||||
emailSuccess: '',
|
||||
loading: false,
|
||||
// API Key management state
|
||||
hasApiKey: false,
|
||||
apiKey: '',
|
||||
apiKeyDisplay: '',
|
||||
apiKeyError: '',
|
||||
apiKeySuccess: '',
|
||||
loadingApiKey: false,
|
||||
copySnackbarOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Load user data
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
try {
|
||||
const user = JSON.parse(storedUser);
|
||||
this.setState({ newEmail: user.email || '' });
|
||||
|
||||
// Check if user has an API key
|
||||
this.props.socket.emit('isApiKey', (response) => {
|
||||
if (response.success && response.hasApiKey) {
|
||||
this.setState({
|
||||
hasApiKey: true,
|
||||
apiKeyDisplay: '************'
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading user data:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdatePassword = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Reset states
|
||||
this.setState({
|
||||
passwordError: '',
|
||||
passwordSuccess: ''
|
||||
});
|
||||
|
||||
// Validation
|
||||
if (!this.state.currentPassword || !this.state.newPassword || !this.state.confirmPassword) {
|
||||
this.setState({ passwordError: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.newPassword !== this.state.confirmPassword) {
|
||||
this.setState({ passwordError: 'Die neuen Passwörter stimmen nicht überein' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.newPassword.length < 8) {
|
||||
this.setState({ passwordError: 'Das neue Passwort muss mindestens 8 Zeichen lang sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
// Call socket.io endpoint to update password
|
||||
this.props.socket.emit('updatePassword',
|
||||
{ oldPassword: this.state.currentPassword, newPassword: this.state.newPassword },
|
||||
(response) => {
|
||||
this.setState({ loading: false });
|
||||
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
passwordSuccess: 'Passwort erfolgreich aktualisiert',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
passwordError: response.message || 'Fehler beim Aktualisieren des Passworts'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleUpdateEmail = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Reset states
|
||||
this.setState({
|
||||
emailError: '',
|
||||
emailSuccess: ''
|
||||
});
|
||||
|
||||
// Validation
|
||||
if (!this.state.password || !this.state.newEmail) {
|
||||
this.setState({ emailError: 'Bitte füllen Sie alle Felder aus' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.state.newEmail)) {
|
||||
this.setState({ emailError: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
// Call socket.io endpoint to update email
|
||||
this.props.socket.emit('updateEmail',
|
||||
{ password: this.state.password, email: this.state.newEmail },
|
||||
(response) => {
|
||||
this.setState({ loading: false });
|
||||
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
emailSuccess: 'E-Mail-Adresse erfolgreich aktualisiert',
|
||||
password: ''
|
||||
});
|
||||
|
||||
// Update user in sessionStorage
|
||||
try {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
const user = JSON.parse(storedUser);
|
||||
user.email = this.state.newEmail;
|
||||
sessionStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user in sessionStorage:', error);
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
emailError: response.message || 'Fehler beim Aktualisieren der E-Mail-Adresse'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleGenerateApiKey = () => {
|
||||
this.setState({
|
||||
apiKeyError: '',
|
||||
apiKeySuccess: '',
|
||||
loadingApiKey: true
|
||||
});
|
||||
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
if (!storedUser) {
|
||||
this.setState({
|
||||
apiKeyError: 'Benutzer nicht gefunden',
|
||||
loadingApiKey: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = JSON.parse(storedUser);
|
||||
|
||||
this.props.socket.emit('createApiKey', user.id, (response) => {
|
||||
this.setState({ loadingApiKey: false });
|
||||
|
||||
if (response.success) {
|
||||
this.setState({
|
||||
hasApiKey: true,
|
||||
apiKey: response.apiKey,
|
||||
apiKeyDisplay: response.apiKey,
|
||||
apiKeySuccess: response.message || 'API-Schlüssel erfolgreich generiert'
|
||||
});
|
||||
|
||||
// After 10 seconds, hide the actual key and show asterisks
|
||||
setTimeout(() => {
|
||||
this.setState({ apiKeyDisplay: '************' });
|
||||
}, 10000);
|
||||
} else {
|
||||
this.setState({
|
||||
apiKeyError: response.message || 'Fehler beim Generieren des API-Schlüssels'
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating API key:', error);
|
||||
this.setState({
|
||||
apiKeyError: 'Fehler beim Generieren des API-Schlüssels',
|
||||
loadingApiKey: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleCopyToClipboard = () => {
|
||||
navigator.clipboard.writeText(this.state.apiKey).then(() => {
|
||||
this.setState({ copySnackbarOpen: true });
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = this.state.apiKey;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
this.setState({ copySnackbarOpen: true });
|
||||
});
|
||||
};
|
||||
|
||||
handleCloseSnackbar = () => {
|
||||
this.setState({ copySnackbarOpen: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Paper sx={{ p: 3}}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
Passwort ändern
|
||||
</Typography>
|
||||
|
||||
{this.state.passwordError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.passwordError}</Alert>}
|
||||
{this.state.passwordSuccess && <Alert severity="success" sx={{ mb: 2 }}>{this.state.passwordSuccess}</Alert>}
|
||||
|
||||
<Box component="form" onSubmit={this.handleUpdatePassword}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Aktuelles Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.currentPassword}
|
||||
onChange={(e) => this.setState({ currentPassword: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Neues Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.newPassword}
|
||||
onChange={(e) => this.setState({ newPassword: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Neues Passwort bestätigen"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.confirmPassword}
|
||||
onChange={(e) => this.setState({ confirmPassword: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||
disabled={this.state.loading}
|
||||
>
|
||||
{this.state.loading ? <CircularProgress size={24} /> : 'Passwort aktualisieren'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
E-Mail-Adresse ändern
|
||||
</Typography>
|
||||
|
||||
{this.state.emailError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.emailError}</Alert>}
|
||||
{this.state.emailSuccess && <Alert severity="success" sx={{ mb: 2 }}>{this.state.emailSuccess}</Alert>}
|
||||
|
||||
<Box component="form" onSubmit={this.handleUpdateEmail}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={this.state.password}
|
||||
onChange={(e) => this.setState({ password: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
label="Neue E-Mail-Adresse"
|
||||
type="email"
|
||||
fullWidth
|
||||
value={this.state.newEmail}
|
||||
onChange={(e) => this.setState({ newEmail: e.target.value })}
|
||||
disabled={this.state.loading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
|
||||
disabled={this.state.loading}
|
||||
>
|
||||
{this.state.loading ? <CircularProgress size={24} /> : 'E-Mail aktualisieren'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
API-Schlüssel
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Verwenden Sie Ihren API-Schlüssel für die Integration mit externen Anwendungen.
|
||||
</Typography>
|
||||
|
||||
{this.state.apiKeyError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.apiKeyError}</Alert>}
|
||||
{this.state.apiKeySuccess && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{this.state.apiKeySuccess}
|
||||
{this.state.apiKey && this.state.apiKeyDisplay !== '************' && (
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
Speichern Sie diesen Schlüssel sicher. Er wird aus Sicherheitsgründen in 10 Sekunden ausgeblendet.
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
API-Dokumentation: {' '}
|
||||
<a
|
||||
href={`${window.location.protocol}//${window.location.host}/api/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#2e7d32' }}
|
||||
>
|
||||
{`${window.location.protocol}//${window.location.host}/api/`}
|
||||
</a>
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 2 }}>
|
||||
<TextField
|
||||
label="API-Schlüssel"
|
||||
value={this.state.apiKeyDisplay}
|
||||
disabled
|
||||
fullWidth
|
||||
sx={{
|
||||
'& .MuiInputBase-input.Mui-disabled': {
|
||||
WebkitTextFillColor: this.state.apiKeyDisplay === '************' ? '#666' : '#000',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{this.state.apiKeyDisplay !== '************' && this.state.apiKey && (
|
||||
<IconButton
|
||||
onClick={this.handleCopyToClipboard}
|
||||
sx={{
|
||||
color: '#2e7d32',
|
||||
'&:hover': { bgcolor: 'rgba(46, 125, 50, 0.1)' }
|
||||
}}
|
||||
title="In Zwischenablage kopieren"
|
||||
>
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={this.handleGenerateApiKey}
|
||||
disabled={this.state.loadingApiKey}
|
||||
sx={{
|
||||
minWidth: 120,
|
||||
bgcolor: '#2e7d32',
|
||||
'&:hover': { bgcolor: '#1b5e20' }
|
||||
}}
|
||||
>
|
||||
{this.state.loadingApiKey ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
this.state.hasApiKey ? 'Regenerieren' : 'Generieren'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Snackbar
|
||||
open={this.state.copySnackbarOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={this.handleCloseSnackbar}
|
||||
message="API-Schlüssel in Zwischenablage kopiert"
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SettingsTab;
|
||||
20
src/components/withRouter.js
Normal file
20
src/components/withRouter.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useNavigate, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
export function withRouter(Component) {
|
||||
function ComponentWithRouterProp(props) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
navigate={navigate}
|
||||
location={location}
|
||||
params={params}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return ComponentWithRouterProp;
|
||||
}
|
||||
Reference in New Issue
Block a user