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

View File

@@ -0,0 +1,138 @@
import React from "react";
import { Box, TextField, Typography } from "@mui/material";
const AddressForm = ({ title, address, onChange, errors, namePrefix }) => {
// Helper function to determine if a required field should show error styling
const getRequiredFieldError = (fieldName, value) => {
const isEmpty = !value || value.trim() === "";
return isEmpty;
};
// Helper function to get label styling for required fields
const getRequiredFieldLabelSx = (fieldName, value) => {
const showError = getRequiredFieldError(fieldName, value);
return showError
? {
"&.MuiInputLabel-shrink": {
color: "#d32f2f", // Material-UI error color
},
}
: {};
};
return (
<>
<Typography variant="h6" gutterBottom>
{title}
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
mt: 3,
mb: 2,
}}
>
<TextField
label="Vorname"
name="firstName"
value={address.firstName}
onChange={onChange}
fullWidth
error={!!errors[`${namePrefix}FirstName`]}
helperText={errors[`${namePrefix}FirstName`]}
InputLabelProps={{
shrink: true,
sx: getRequiredFieldLabelSx("firstName", address.firstName),
}}
/>
<TextField
label="Nachname"
name="lastName"
value={address.lastName}
onChange={onChange}
fullWidth
error={!!errors[`${namePrefix}LastName`]}
helperText={errors[`${namePrefix}LastName`]}
InputLabelProps={{
shrink: true,
sx: getRequiredFieldLabelSx("lastName", address.lastName),
}}
/>
<TextField
label="Adresszusatz"
name="addressAddition"
value={address.addressAddition || ""}
onChange={onChange}
fullWidth
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Straße"
name="street"
value={address.street}
onChange={onChange}
fullWidth
error={!!errors[`${namePrefix}Street`]}
helperText={errors[`${namePrefix}Street`]}
InputLabelProps={{
shrink: true,
sx: getRequiredFieldLabelSx("street", address.street),
}}
/>
<TextField
label="Hausnummer"
name="houseNumber"
value={address.houseNumber}
onChange={onChange}
fullWidth
error={!!errors[`${namePrefix}HouseNumber`]}
helperText={errors[`${namePrefix}HouseNumber`]}
InputLabelProps={{
shrink: true,
sx: getRequiredFieldLabelSx("houseNumber", address.houseNumber),
}}
/>
<TextField
label="PLZ"
name="postalCode"
value={address.postalCode}
onChange={onChange}
fullWidth
error={!!errors[`${namePrefix}PostalCode`]}
helperText={errors[`${namePrefix}PostalCode`]}
InputLabelProps={{
shrink: true,
sx: getRequiredFieldLabelSx("postalCode", address.postalCode),
}}
/>
<TextField
label="Stadt"
name="city"
value={address.city}
onChange={onChange}
fullWidth
error={!!errors[`${namePrefix}City`]}
helperText={errors[`${namePrefix}City`]}
InputLabelProps={{
shrink: true,
sx: getRequiredFieldLabelSx("city", address.city),
}}
/>
<TextField
label="Land"
name="country"
value={address.country}
onChange={onChange}
fullWidth
disabled
InputLabelProps={{ shrink: true }}
/>
</Box>
</>
);
};
export default AddressForm;

View File

@@ -0,0 +1,510 @@
import React, { Component } from "react";
import { Box, Typography, Button } from "@mui/material";
import CartDropdown from "../CartDropdown.js";
import CheckoutForm from "./CheckoutForm.js";
import PaymentConfirmationDialog from "./PaymentConfirmationDialog.js";
import OrderProcessingService from "./OrderProcessingService.js";
import CheckoutValidation from "./CheckoutValidation.js";
import SocketContext from "../../contexts/SocketContext.js";
class CartTab extends Component {
constructor(props) {
super(props);
const initialCartItems = Array.isArray(window.cart) ? window.cart : [];
const initialDeliveryMethod = CheckoutValidation.shouldForcePickupDelivery(initialCartItems) ? "Abholung" : "DHL";
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(initialDeliveryMethod, initialCartItems, 0);
this.state = {
isCheckingOut: false,
cartItems: initialCartItems,
deliveryMethod: initialDeliveryMethod,
paymentMethod: optimalPaymentMethod,
invoiceAddress: {
firstName: "",
lastName: "",
addressAddition: "",
street: "",
houseNumber: "",
postalCode: "",
city: "",
country: "Deutschland",
},
deliveryAddress: {
firstName: "",
lastName: "",
addressAddition: "",
street: "",
houseNumber: "",
postalCode: "",
city: "",
country: "Deutschland",
},
useSameAddress: true,
saveAddressForFuture: true,
addressFormErrors: {},
termsAccepted: false,
isCompletingOrder: false,
completionError: null,
note: "",
stripeClientSecret: null,
showStripePayment: false,
StripeComponent: null,
isLoadingStripe: false,
showPaymentConfirmation: false,
orderCompleted: false,
originalCartItems: []
};
// Initialize order processing service
this.orderService = new OrderProcessingService(
() => this.context,
this.setState.bind(this)
);
this.orderService.getState = () => this.state;
this.orderService.setOrderSuccessCallback(this.props.onOrderSuccess);
}
// @note Add method to fetch and apply order template prefill data
fetchOrderTemplate = () => {
if (this.context && this.context.connected) {
this.context.emit('getOrderTemplate', (response) => {
if (response.success && response.orderTemplate) {
const template = response.orderTemplate;
// Map the template fields to our state structure
const invoiceAddress = {
firstName: template.invoice_address_name ? template.invoice_address_name.split(' ')[0] || "" : "",
lastName: template.invoice_address_name ? template.invoice_address_name.split(' ').slice(1).join(' ') || "" : "",
addressAddition: template.invoice_address_line2 || "",
street: template.invoice_address_street || "",
houseNumber: template.invoice_address_house_number || "",
postalCode: template.invoice_address_postal_code || "",
city: template.invoice_address_city || "",
country: template.invoice_address_country || "Deutschland",
};
const deliveryAddress = {
firstName: template.shipping_address_name ? template.shipping_address_name.split(' ')[0] || "" : "",
lastName: template.shipping_address_name ? template.shipping_address_name.split(' ').slice(1).join(' ') || "" : "",
addressAddition: template.shipping_address_line2 || "",
street: template.shipping_address_street || "",
houseNumber: template.shipping_address_house_number || "",
postalCode: template.shipping_address_postal_code || "",
city: template.shipping_address_city || "",
country: template.shipping_address_country || "Deutschland",
};
// Get current cart state to check constraints
const currentCartItems = Array.isArray(window.cart) ? window.cart : [];
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(currentCartItems);
// Determine delivery method - respect cart constraints
let prefillDeliveryMethod = template.delivery_method || "DHL";
if (isPickupOnly || hasStecklinge) {
prefillDeliveryMethod = "Abholung";
}
// Map delivery method values if needed
const deliveryMethodMap = {
"standard": "DHL",
"express": "DPD",
"pickup": "Abholung"
};
prefillDeliveryMethod = deliveryMethodMap[prefillDeliveryMethod] || prefillDeliveryMethod;
// Determine payment method - respect constraints
let prefillPaymentMethod = template.payment_method || "wire";
const paymentMethodMap = {
"credit_card": "stripe",
"bank_transfer": "wire",
"cash_on_delivery": "onDelivery",
"cash": "cash"
};
prefillPaymentMethod = paymentMethodMap[prefillPaymentMethod] || prefillPaymentMethod;
// Validate payment method against delivery method constraints
prefillPaymentMethod = CheckoutValidation.validatePaymentMethodForDelivery(
prefillDeliveryMethod,
prefillPaymentMethod,
currentCartItems,
0 // Use 0 for delivery cost during prefill
);
// Apply prefill data to state
this.setState({
invoiceAddress,
deliveryAddress,
deliveryMethod: prefillDeliveryMethod,
paymentMethod: prefillPaymentMethod,
saveAddressForFuture: template.save_address_for_future === 1,
useSameAddress: true // Default to same address, user can change if needed
});
console.log("Order template applied successfully");
} else {
console.log("No order template available or failed to fetch");
}
});
}
};
componentDidMount() {
// Handle payment completion if detected
if (this.props.paymentCompletion) {
this.orderService.handlePaymentCompletion(
this.props.paymentCompletion,
this.props.onClearPaymentCompletion
);
}
// @note Fetch order template for prefill when component mounts
this.fetchOrderTemplate();
this.cart = () => {
// @note Don't update cart if we're showing payment confirmation - keep it empty
if (this.state.showPaymentConfirmation) {
return;
}
const cartItems = Array.isArray(window.cart) ? window.cart : [];
const shouldForcePickup = CheckoutValidation.shouldForcePickupDelivery(cartItems);
const newDeliveryMethod = shouldForcePickup ? "Abholung" : this.state.deliveryMethod;
const deliveryCost = this.orderService.getDeliveryCost();
// Get optimal payment method for the current state
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(
newDeliveryMethod,
cartItems,
deliveryCost
);
// Use optimal payment method if current one is invalid, otherwise keep current
const validatedPaymentMethod = CheckoutValidation.validatePaymentMethodForDelivery(
newDeliveryMethod,
this.state.paymentMethod,
cartItems,
deliveryCost
);
const newPaymentMethod = validatedPaymentMethod !== this.state.paymentMethod
? optimalPaymentMethod
: this.state.paymentMethod;
this.setState({
cartItems,
deliveryMethod: newDeliveryMethod,
paymentMethod: newPaymentMethod,
});
};
window.addEventListener("cart", this.cart);
this.cart(); // Initial check
}
componentWillUnmount() {
window.removeEventListener("cart", this.cart);
this.orderService.cleanup();
}
handleCheckout = () => {
this.setState({ isCheckingOut: true });
};
handleContinueShopping = () => {
this.setState({ isCheckingOut: false });
};
handleDeliveryMethodChange = (event) => {
const newDeliveryMethod = event.target.value;
const deliveryCost = this.orderService.getDeliveryCost();
// Get optimal payment method for the new delivery method
const optimalPaymentMethod = CheckoutValidation.getOptimalPaymentMethod(
newDeliveryMethod,
this.state.cartItems,
deliveryCost
);
// Use optimal payment method if current one becomes invalid, otherwise keep current
const validatedPaymentMethod = CheckoutValidation.validatePaymentMethodForDelivery(
newDeliveryMethod,
this.state.paymentMethod,
this.state.cartItems,
deliveryCost
);
const newPaymentMethod = validatedPaymentMethod !== this.state.paymentMethod
? optimalPaymentMethod
: this.state.paymentMethod;
this.setState({
deliveryMethod: newDeliveryMethod,
paymentMethod: newPaymentMethod,
});
};
handlePaymentMethodChange = (event) => {
this.setState({ paymentMethod: event.target.value });
};
handleInvoiceAddressChange = (e) => {
const { name, value } = e.target;
this.setState((prevState) => ({
invoiceAddress: {
...prevState.invoiceAddress,
[name]: value,
},
}));
};
handleDeliveryAddressChange = (e) => {
const { name, value } = e.target;
this.setState((prevState) => ({
deliveryAddress: {
...prevState.deliveryAddress,
[name]: value,
},
}));
};
handleUseSameAddressChange = (e) => {
const useSameAddress = e.target.checked;
this.setState({
useSameAddress,
deliveryAddress: useSameAddress
? this.state.invoiceAddress
: this.state.deliveryAddress,
});
};
handleTermsAcceptedChange = (e) => {
this.setState({ termsAccepted: e.target.checked });
};
handleNoteChange = (e) => {
this.setState({ note: e.target.value });
};
handleSaveAddressForFutureChange = (e) => {
this.setState({ saveAddressForFuture: e.target.checked });
};
validateAddressForm = () => {
const errors = CheckoutValidation.validateAddressForm(this.state);
this.setState({ addressFormErrors: errors });
return Object.keys(errors).length === 0;
};
loadStripeComponent = async (clientSecret) => {
this.setState({ isLoadingStripe: true });
try {
const { default: Stripe } = await import("../Stripe.js");
this.setState({
StripeComponent: Stripe,
stripeClientSecret: clientSecret,
showStripePayment: true,
isCompletingOrder: false,
isLoadingStripe: false,
});
} catch (error) {
console.error("Failed to load Stripe component:", error);
this.setState({
isCompletingOrder: false,
isLoadingStripe: false,
completionError: "Failed to load payment component. Please try again.",
});
}
};
handleCompleteOrder = () => {
this.setState({ completionError: null }); // Clear previous errors
const validationError = CheckoutValidation.getValidationErrorMessage(this.state);
if (validationError) {
this.setState({ completionError: validationError });
this.validateAddressForm(); // To show field-specific errors
return;
}
this.setState({ isCompletingOrder: true });
const {
deliveryMethod,
paymentMethod,
invoiceAddress,
deliveryAddress,
useSameAddress,
cartItems,
note,
saveAddressForFuture,
} = this.state;
const deliveryCost = this.orderService.getDeliveryCost();
// Handle Stripe payment differently
if (paymentMethod === "stripe") {
// Store the cart items used for Stripe payment in sessionStorage for later reference
try {
sessionStorage.setItem('stripePaymentCart', JSON.stringify(cartItems));
} catch (error) {
console.error("Failed to store Stripe payment cart:", error);
}
// Calculate total amount for Stripe
const subtotal = cartItems.reduce(
(total, item) => total + item.price * item.quantity,
0
);
const totalAmount = Math.round((subtotal + deliveryCost) * 100); // Convert to cents
this.orderService.createStripeIntent(totalAmount, this.loadStripeComponent);
return;
}
// Handle regular orders
const orderData = {
items: cartItems,
invoiceAddress,
deliveryAddress: useSameAddress ? invoiceAddress : deliveryAddress,
deliveryMethod,
paymentMethod,
deliveryCost,
note,
domain: window.location.origin,
saveAddressForFuture,
};
this.orderService.processRegularOrder(orderData);
};
render() {
const {
cartItems,
deliveryMethod,
paymentMethod,
invoiceAddress,
deliveryAddress,
useSameAddress,
saveAddressForFuture,
addressFormErrors,
termsAccepted,
isCompletingOrder,
completionError,
note,
stripeClientSecret,
showStripePayment,
StripeComponent,
isLoadingStripe,
showPaymentConfirmation,
orderCompleted,
} = this.state;
const deliveryCost = this.orderService.getDeliveryCost();
const { isPickupOnly, hasStecklinge } = CheckoutValidation.getCartItemFlags(cartItems);
const preSubmitError = CheckoutValidation.getValidationErrorMessage(this.state);
const displayError = completionError || preSubmitError;
return (
<Box sx={{ p: 3 }}>
{/* Payment Confirmation */}
{showPaymentConfirmation && (
<PaymentConfirmationDialog
paymentCompletionData={this.orderService.paymentCompletionData}
isCompletingOrder={isCompletingOrder}
completionError={completionError}
orderCompleted={orderCompleted}
onContinueShopping={() => {
this.setState({ showPaymentConfirmation: false });
}}
onViewOrders={() => {
if (this.props.onOrderSuccess) {
this.props.onOrderSuccess();
}
this.setState({ showPaymentConfirmation: false });
}}
/>
)}
{/* @note Hide CartDropdown when showing payment confirmation */}
{!showPaymentConfirmation && (
<CartDropdown
cartItems={cartItems}
socket={this.context}
showDetailedSummary={showStripePayment}
deliveryMethod={deliveryMethod}
deliveryCost={deliveryCost}
/>
)}
{cartItems.length > 0 && (
<Box sx={{ mt: 3 }}>
{isLoadingStripe ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body1">
Zahlungskomponente wird geladen...
</Typography>
</Box>
) : showStripePayment && StripeComponent ? (
<>
<Box sx={{ mb: 2 }}>
<Button
variant="outlined"
onClick={() => this.setState({ showStripePayment: false, stripeClientSecret: null })}
sx={{
color: '#2e7d32',
borderColor: '#2e7d32',
'&:hover': {
backgroundColor: 'rgba(46, 125, 50, 0.04)',
borderColor: '#1b5e20'
}
}}
>
Zurück zur Bestellung
</Button>
</Box>
<StripeComponent clientSecret={stripeClientSecret} />
</>
) : (
<CheckoutForm
paymentMethod={paymentMethod}
invoiceAddress={invoiceAddress}
deliveryAddress={deliveryAddress}
useSameAddress={useSameAddress}
saveAddressForFuture={saveAddressForFuture}
addressFormErrors={addressFormErrors}
termsAccepted={termsAccepted}
note={note}
deliveryMethod={deliveryMethod}
hasStecklinge={hasStecklinge}
isPickupOnly={isPickupOnly}
deliveryCost={deliveryCost}
cartItems={cartItems}
displayError={displayError}
isCompletingOrder={isCompletingOrder}
preSubmitError={preSubmitError}
onInvoiceAddressChange={this.handleInvoiceAddressChange}
onDeliveryAddressChange={this.handleDeliveryAddressChange}
onUseSameAddressChange={this.handleUseSameAddressChange}
onSaveAddressForFutureChange={this.handleSaveAddressForFutureChange}
onTermsAcceptedChange={this.handleTermsAcceptedChange}
onNoteChange={this.handleNoteChange}
onDeliveryMethodChange={this.handleDeliveryMethodChange}
onPaymentMethodChange={this.handlePaymentMethodChange}
onCompleteOrder={this.handleCompleteOrder}
/>
)}
</Box>
)}
</Box>
);
}
}
// Set static contextType to access the socket
CartTab.contextType = SocketContext;
export default CartTab;

View File

@@ -0,0 +1,185 @@
import React, { Component } from "react";
import { Box, Typography, TextField, Checkbox, FormControlLabel, Button } from "@mui/material";
import AddressForm from "./AddressForm.js";
import DeliveryMethodSelector from "./DeliveryMethodSelector.js";
import PaymentMethodSelector from "./PaymentMethodSelector.js";
import OrderSummary from "./OrderSummary.js";
class CheckoutForm extends Component {
render() {
const {
paymentMethod,
invoiceAddress,
deliveryAddress,
useSameAddress,
saveAddressForFuture,
addressFormErrors,
termsAccepted,
note,
deliveryMethod,
hasStecklinge,
isPickupOnly,
deliveryCost,
cartItems,
displayError,
isCompletingOrder,
preSubmitError,
onInvoiceAddressChange,
onDeliveryAddressChange,
onUseSameAddressChange,
onSaveAddressForFutureChange,
onTermsAcceptedChange,
onNoteChange,
onDeliveryMethodChange,
onPaymentMethodChange,
onCompleteOrder,
} = this.props;
return (
<>
{paymentMethod !== "cash" && (
<>
<AddressForm
title="Rechnungsadresse"
address={invoiceAddress}
onChange={onInvoiceAddressChange}
errors={addressFormErrors}
namePrefix="invoice"
/>
<FormControlLabel
control={
<Checkbox
checked={saveAddressForFuture}
onChange={onSaveAddressForFutureChange}
sx={{ '& .MuiSvgIcon-root': { fontSize: 28 } }}
/>
}
label={
<Typography variant="body2">
Für zukünftige Bestellungen speichern
</Typography>
}
sx={{ mb: 2 }}
/>
</>
)}
{hasStecklinge && (
<Typography
variant="body1"
sx={{ mb: 2, fontWeight: "bold", color: "#2e7d32" }}
>
Für welchen Termin ist die Abholung der Stecklinge
gewünscht?
</Typography>
)}
<TextField
label="Anmerkung"
name="note"
value={note}
onChange={onNoteChange}
fullWidth
multiline
rows={3}
margin="normal"
variant="outlined"
sx={{ mb: 2 }}
InputLabelProps={{ shrink: true }}
/>
<DeliveryMethodSelector
deliveryMethod={deliveryMethod}
onChange={onDeliveryMethodChange}
isPickupOnly={isPickupOnly || hasStecklinge}
/>
{(deliveryMethod === "DHL" || deliveryMethod === "DPD") && (
<>
<FormControlLabel
control={
<Checkbox
checked={useSameAddress}
onChange={onUseSameAddressChange}
sx={{ '& .MuiSvgIcon-root': { fontSize: 28 } }}
/>
}
label={
<Typography variant="body1">
Lieferadresse ist identisch mit Rechnungsadresse
</Typography>
}
sx={{ mb: 2 }}
/>
{!useSameAddress && (
<AddressForm
title="Lieferadresse"
address={deliveryAddress}
onChange={onDeliveryAddressChange}
errors={addressFormErrors}
namePrefix="delivery"
/>
)}
</>
)}
<PaymentMethodSelector
paymentMethod={paymentMethod}
onChange={onPaymentMethodChange}
deliveryMethod={deliveryMethod}
onDeliveryMethodChange={onDeliveryMethodChange}
cartItems={cartItems}
deliveryCost={deliveryCost}
/>
<OrderSummary deliveryCost={deliveryCost} cartItems={cartItems} />
<FormControlLabel
control={
<Checkbox
checked={termsAccepted}
onChange={onTermsAcceptedChange}
sx={{
'& .MuiSvgIcon-root': { fontSize: 28 },
alignSelf: 'flex-start',
mt: -0.5
}}
/>
}
label={
<Typography variant="body2">
Ich habe die AGBs, die Datenschutzerklärung und die
Bestimmungen zum Widerrufsrecht gelesen
</Typography>
}
sx={{ mb: 3, mt: 2 }}
/>
{/* @note Reserve space for error message to prevent layout shift */}
<Box sx={{ minHeight: '24px', mb: 2, textAlign: "center" }}>
{displayError && (
<Typography color="error" sx={{ lineHeight: '24px' }}>
{displayError}
</Typography>
)}
</Box>
<Button
variant="contained"
fullWidth
sx={{ bgcolor: "#2e7d32", "&:hover": { bgcolor: "#1b5e20" } }}
onClick={onCompleteOrder}
disabled={isCompletingOrder || !!preSubmitError}
>
{isCompletingOrder
? "Bestellung wird verarbeitet..."
: "Bestellung abschließen"}
</Button>
</>
);
}
}
export default CheckoutForm;

View File

@@ -0,0 +1,150 @@
class CheckoutValidation {
static validateAddressForm(state) {
const {
invoiceAddress,
deliveryAddress,
useSameAddress,
deliveryMethod,
paymentMethod,
} = state;
const errors = {};
// Validate invoice address (skip if payment method is "cash")
if (paymentMethod !== "cash") {
if (!invoiceAddress.firstName)
errors.invoiceFirstName = "Vorname erforderlich";
if (!invoiceAddress.lastName)
errors.invoiceLastName = "Nachname erforderlich";
if (!invoiceAddress.street) errors.invoiceStreet = "Straße erforderlich";
if (!invoiceAddress.houseNumber)
errors.invoiceHouseNumber = "Hausnummer erforderlich";
if (!invoiceAddress.postalCode)
errors.invoicePostalCode = "PLZ erforderlich";
if (!invoiceAddress.city) errors.invoiceCity = "Stadt erforderlich";
}
// Validate delivery address for shipping methods that require it
if (
!useSameAddress &&
(deliveryMethod === "DHL" || deliveryMethod === "DPD")
) {
if (!deliveryAddress.firstName)
errors.deliveryFirstName = "Vorname erforderlich";
if (!deliveryAddress.lastName)
errors.deliveryLastName = "Nachname erforderlich";
if (!deliveryAddress.street)
errors.deliveryStreet = "Straße erforderlich";
if (!deliveryAddress.houseNumber)
errors.deliveryHouseNumber = "Hausnummer erforderlich";
if (!deliveryAddress.postalCode)
errors.deliveryPostalCode = "PLZ erforderlich";
if (!deliveryAddress.city) errors.deliveryCity = "Stadt erforderlich";
}
return errors;
}
static getValidationErrorMessage(state, isAddressOnly = false) {
const { termsAccepted } = state;
const addressErrors = this.validateAddressForm(state);
if (isAddressOnly) {
return addressErrors;
}
if (Object.keys(addressErrors).length > 0) {
return "Bitte überprüfen Sie Ihre Eingaben in den Adressfeldern.";
}
// Validate terms acceptance
if (!termsAccepted) {
return "Bitte akzeptieren Sie die AGBs, Datenschutzerklärung und Widerrufsrecht, um fortzufahren.";
}
return null;
}
static getOptimalPaymentMethod(deliveryMethod, cartItems = [], deliveryCost = 0) {
// Calculate total amount
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
const totalAmount = subtotal + deliveryCost;
// If total is 0, only cash is allowed
if (totalAmount === 0) {
return "cash";
}
// If total is less than 0.50€, stripe is not available
if (totalAmount < 0.50) {
return "wire";
}
// Prefer stripe when available and meets minimum amount
if (deliveryMethod === "DHL" || deliveryMethod === "DPD" || deliveryMethod === "Abholung") {
return "stripe";
}
// Fall back to wire transfer
return "wire";
}
static validatePaymentMethodForDelivery(deliveryMethod, paymentMethod, cartItems = [], deliveryCost = 0) {
let newPaymentMethod = paymentMethod;
// Calculate total amount for minimum validation
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
const totalAmount = subtotal + deliveryCost;
// Reset payment method if it's no longer valid
if (deliveryMethod !== "DHL" && paymentMethod === "onDelivery") {
newPaymentMethod = "wire";
}
// Allow stripe for DHL, DPD, and Abholung delivery methods, but check minimum amount
if (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung" && paymentMethod === "stripe") {
newPaymentMethod = "wire";
}
// Check minimum amount for stripe payments
if (paymentMethod === "stripe" && totalAmount < 0.50) {
newPaymentMethod = "wire";
}
if (deliveryMethod !== "Abholung" && paymentMethod === "cash") {
newPaymentMethod = "wire";
}
return newPaymentMethod;
}
static shouldForcePickupDelivery(cartItems) {
const isPickupOnly = cartItems.some(
(item) => item.versandklasse === "nur Abholung"
);
const hasStecklinge = cartItems.some(
(item) =>
item.id &&
typeof item.id === "string" &&
item.id.endsWith("steckling")
);
return isPickupOnly || hasStecklinge;
}
static getCartItemFlags(cartItems) {
const isPickupOnly = cartItems.some(
(item) => item.versandklasse === "nur Abholung"
);
const hasStecklinge = cartItems.some(
(item) =>
item.id &&
typeof item.id === "string" &&
item.id.endsWith("steckling")
);
return { isPickupOnly, hasStecklinge };
}
}
export default CheckoutValidation;

View File

@@ -0,0 +1,122 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Radio from '@mui/material/Radio';
import Checkbox from '@mui/material/Checkbox';
const DeliveryMethodSelector = ({ deliveryMethod, onChange, isPickupOnly }) => {
const deliveryOptions = [
{
id: 'DHL',
name: 'DHL',
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
price: '6,99 €',
disabled: isPickupOnly
},
{
id: 'DPD',
name: 'DPD',
description: isPickupOnly ? "nicht auswählbar weil ein oder mehrere Artikel nur abgeholt werden können" : 'Standardversand',
price: '4,90 €',
disabled: isPickupOnly
},
{
id: 'Sperrgut',
name: 'Sperrgut',
description: 'Für große und schwere Artikel',
price: '28,99 €',
disabled: true,
isCheckbox: true
},
{
id: 'Abholung',
name: 'Abholung in der Filiale',
description: '',
price: ''
}
];
return (
<>
<Typography variant="h6" gutterBottom>
Versandart wählen
</Typography>
<Box sx={{ mb: 3 }}>
{deliveryOptions.map((option, index) => (
<Box
key={option.id}
sx={{
display: 'flex',
alignItems: 'center',
mb: index < deliveryOptions.length - 1 ? 1 : 0,
p: 1,
border: '1px solid #e0e0e0',
borderRadius: 1,
cursor: option.disabled ? 'not-allowed' : 'pointer',
backgroundColor: option.disabled ? '#f5f5f5' : 'transparent',
transition: 'all 0.2s ease-in-out',
'&:hover': !option.disabled ? {
backgroundColor: '#f5f5f5',
borderColor: '#2e7d32',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
} : {},
...(deliveryMethod === option.id && !option.disabled && {
backgroundColor: '#e8f5e8',
borderColor: '#2e7d32'
})
}}
onClick={!option.disabled && !option.isCheckbox ? () => onChange({ target: { value: option.id } }) : undefined}
>
{option.isCheckbox ? (
<Checkbox
id={option.id}
disabled={option.disabled}
checked={false}
sx={{ color: 'rgba(0, 0, 0, 0.54)' }}
/>
) : (
<Radio
id={option.id}
name="deliveryMethod"
value={option.id}
checked={deliveryMethod === option.id}
onChange={onChange}
disabled={option.disabled}
sx={{ cursor: option.disabled ? 'not-allowed' : 'pointer' }}
/>
)}
<Box sx={{ ml: 2, flexGrow: 1 }}>
<label
htmlFor={option.id}
style={{
cursor: option.disabled ? 'not-allowed' : 'pointer',
color: option.disabled ? 'rgba(0, 0, 0, 0.54)' : 'inherit'
}}
>
<Typography variant="body1" sx={{ color: 'inherit' }}>
{option.name}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ color: 'inherit' }}
>
{option.description}
</Typography>
</label>
</Box>
<Typography
variant="body1"
sx={{ color: option.disabled ? 'rgba(0, 0, 0, 0.54)' : 'inherit' }}
>
{option.price}
</Typography>
</Box>
))}
</Box>
</>
);
};
export default DeliveryMethodSelector;

View File

@@ -0,0 +1,171 @@
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper
} from '@mui/material';
const OrderDetailsDialog = ({ open, onClose, order }) => {
if (!order) {
return null;
}
const currencyFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
const handleCancelOrder = () => {
// Implement order cancellation logic here
console.log(`Cancel order: ${order.orderId}`);
onClose(); // Close the dialog after action
};
const subtotal = order.items.reduce((acc, item) => acc + item.price * item.quantity_ordered, 0);
const total = subtotal + order.delivery_cost;
// Calculate VAT breakdown similar to CartDropdown
const vatCalculations = order.items.reduce((acc, item) => {
const totalItemPrice = item.price * item.quantity_ordered;
const netPrice = totalItemPrice / (1 + item.vat / 100);
const vatAmount = totalItemPrice - netPrice;
acc.totalGross += totalItemPrice;
acc.totalNet += netPrice;
if (item.vat === 7) {
acc.vat7 += vatAmount;
} else if (item.vat === 19) {
acc.vat19 += vatAmount;
}
return acc;
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Bestelldetails: {order.orderId}</DialogTitle>
<DialogContent>
<Box sx={{ mb: 2 }}>
<Typography variant="h6">Lieferadresse</Typography>
<Typography>{order.shipping_address_name}</Typography>
<Typography>{order.shipping_address_street} {order.shipping_address_house_number}</Typography>
<Typography>{order.shipping_address_postal_code} {order.shipping_address_city}</Typography>
<Typography>{order.shipping_address_country}</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="h6">Rechnungsadresse</Typography>
<Typography>{order.invoice_address_name}</Typography>
<Typography>{order.invoice_address_street} {order.invoice_address_house_number}</Typography>
<Typography>{order.invoice_address_postal_code} {order.invoice_address_city}</Typography>
<Typography>{order.invoice_address_country}</Typography>
</Box>
{/* Order Details Section */}
<Box sx={{ mb: 2 }}>
<Typography variant="h6" gutterBottom>Bestelldetails</Typography>
<Box sx={{ display: 'flex', gap: 4 }}>
<Box>
<Typography variant="body2" color="text.secondary">Lieferart:</Typography>
<Typography variant="body1">{order.deliveryMethod || order.delivery_method || 'Nicht angegeben'}</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">Zahlungsart:</Typography>
<Typography variant="body1">{order.paymentMethod || order.payment_method || 'Nicht angegeben'}</Typography>
</Box>
</Box>
</Box>
<Typography variant="h6" gutterBottom>Bestellte Artikel</Typography>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Artikel</TableCell>
<TableCell align="right">Menge</TableCell>
<TableCell align="right">Preis</TableCell>
<TableCell align="right">Gesamt</TableCell>
</TableRow>
</TableHead>
<TableBody>
{order.items.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell align="right">{item.quantity_ordered}</TableCell>
<TableCell align="right">{currencyFormatter.format(item.price)}</TableCell>
<TableCell align="right">{currencyFormatter.format(item.price * item.quantity_ordered)}</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">
<Typography fontWeight="bold">Gesamtnettopreis</Typography>
</TableCell>
<TableCell align="right">
<Typography fontWeight="bold">{currencyFormatter.format(vatCalculations.totalNet)}</Typography>
</TableCell>
</TableRow>
{vatCalculations.vat7 > 0 && (
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">7% Mehrwertsteuer</TableCell>
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat7)}</TableCell>
</TableRow>
)}
{vatCalculations.vat19 > 0 && (
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">19% Mehrwertsteuer</TableCell>
<TableCell align="right">{currencyFormatter.format(vatCalculations.vat19)}</TableCell>
</TableRow>
)}
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">
<Typography fontWeight="bold">Zwischensumme</Typography>
</TableCell>
<TableCell align="right">
<Typography fontWeight="bold">{currencyFormatter.format(subtotal)}</Typography>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">Lieferkosten</TableCell>
<TableCell align="right">{currencyFormatter.format(order.delivery_cost)}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={2} />
<TableCell align="right">
<Typography fontWeight="bold">Gesamtsumme</Typography>
</TableCell>
<TableCell align="right">
<Typography fontWeight="bold">{currencyFormatter.format(total)}</Typography>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions>
{order.status === 'new' && (
<Button onClick={handleCancelOrder} color="error">
Bestellung stornieren
</Button>
)}
<Button onClick={onClose}>Schließen</Button>
</DialogActions>
</Dialog>
);
};
export default OrderDetailsDialog;

View File

@@ -0,0 +1,315 @@
import { isUserLoggedIn } from "../LoginComponent.js";
class OrderProcessingService {
constructor(getContext, setState) {
this.getContext = getContext;
this.setState = setState;
this.verifyTokenHandler = null;
this.verifyTokenTimeout = null;
this.socketHandler = null;
this.paymentCompletionData = null;
}
// Clean up all event listeners and timeouts
cleanup() {
if (this.verifyTokenHandler) {
window.removeEventListener('cart', this.verifyTokenHandler);
this.verifyTokenHandler = null;
}
if (this.verifyTokenTimeout) {
clearTimeout(this.verifyTokenTimeout);
this.verifyTokenTimeout = null;
}
if (this.socketHandler) {
window.removeEventListener('cart', this.socketHandler);
this.socketHandler = null;
}
}
// Handle payment completion from parent component
handlePaymentCompletion(paymentCompletion, onClearPaymentCompletion) {
// Store payment completion data before clearing
this.paymentCompletionData = { ...paymentCompletion };
// Clear payment completion data to prevent duplicates
if (onClearPaymentCompletion) {
onClearPaymentCompletion();
}
// Show payment confirmation immediately but wait for verifyToken to complete
this.setState({
showPaymentConfirmation: true,
cartItems: [] // Clear UI cart immediately
});
// Wait for verifyToken to complete and populate window.cart, then process order
this.waitForVerifyTokenAndProcessOrder();
}
waitForVerifyTokenAndProcessOrder() {
// Check if window.cart is already populated (verifyToken already completed)
if (Array.isArray(window.cart) && window.cart.length > 0) {
this.processStripeOrderWithCart(window.cart);
return;
}
// Listen for cart event which is dispatched after verifyToken completes
this.verifyTokenHandler = () => {
if (Array.isArray(window.cart) && window.cart.length > 0) {
this.processStripeOrderWithCart([...window.cart]); // Copy the cart
// Clear window.cart after copying
window.cart = [];
window.dispatchEvent(new CustomEvent("cart"));
} else {
this.setState({
completionError: "Cart is empty. Please add items to your cart before placing an order."
});
}
// Clean up listener
if (this.verifyTokenHandler) {
window.removeEventListener('cart', this.verifyTokenHandler);
this.verifyTokenHandler = null;
}
};
window.addEventListener('cart', this.verifyTokenHandler);
// Set up a timeout as fallback (in case verifyToken fails)
this.verifyTokenTimeout = setTimeout(() => {
if (Array.isArray(window.cart) && window.cart.length > 0) {
this.processStripeOrderWithCart([...window.cart]);
window.cart = [];
window.dispatchEvent(new CustomEvent("cart"));
} else {
this.setState({
completionError: "Unable to load cart data. Please refresh the page and try again."
});
}
// Clean up
if (this.verifyTokenHandler) {
window.removeEventListener('cart', this.verifyTokenHandler);
this.verifyTokenHandler = null;
}
}, 5000); // 5 second timeout
}
processStripeOrderWithCart(cartItems) {
// Clear timeout if it exists
if (this.verifyTokenTimeout) {
clearTimeout(this.verifyTokenTimeout);
this.verifyTokenTimeout = null;
}
// Store cart items in state and process order
this.setState({
originalCartItems: cartItems
}, () => {
this.processStripeOrder();
});
}
processStripeOrder() {
// If no original cart items, don't process
if (!this.getState().originalCartItems || this.getState().originalCartItems.length === 0) {
this.setState({ completionError: "Cart is empty. Please add items to your cart before placing an order." });
return;
}
// If socket is ready, process immediately
const context = this.getContext();
if (context && context.connected) {
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
if (isAuthenticated) {
this.sendStripeOrder();
return;
}
}
// Wait for socket to be ready
this.socketHandler = () => {
const context = this.getContext();
if (context && context.connected) {
const { isLoggedIn: isAuthenticated } = isUserLoggedIn();
const state = this.getState();
if (isAuthenticated && state.showPaymentConfirmation && !state.isCompletingOrder) {
this.sendStripeOrder();
}
}
// Clean up
if (this.socketHandler) {
window.removeEventListener('cart', this.socketHandler);
this.socketHandler = null;
}
};
window.addEventListener('cart', this.socketHandler);
}
sendStripeOrder() {
const state = this.getState();
// Don't process if already processing or completed
if (state.isCompletingOrder || state.orderCompleted) {
return;
}
this.setState({ isCompletingOrder: true, completionError: null });
const {
deliveryMethod,
invoiceAddress,
deliveryAddress,
useSameAddress,
originalCartItems,
note,
saveAddressForFuture,
} = state;
const deliveryCost = this.getDeliveryCost();
const orderData = {
items: originalCartItems,
invoiceAddress,
deliveryAddress: useSameAddress ? invoiceAddress : deliveryAddress,
deliveryMethod,
paymentMethod: "stripe",
deliveryCost,
note,
domain: window.location.origin,
stripeData: this.paymentCompletionData ? {
paymentIntent: this.paymentCompletionData.paymentIntent,
paymentIntentClientSecret: this.paymentCompletionData.paymentIntentClientSecret,
redirectStatus: this.paymentCompletionData.redirectStatus,
} : null,
saveAddressForFuture,
};
// Emit stripe order to backend via socket.io
const context = this.getContext();
context.emit("issueStripeOrder", orderData, (response) => {
if (response.success) {
this.setState({
isCompletingOrder: false,
orderCompleted: true,
completionError: null,
});
} else {
this.setState({
isCompletingOrder: false,
completionError: response.error || "Failed to complete order. Please try again.",
});
}
});
}
// Process regular (non-Stripe) orders
processRegularOrder(orderData) {
const context = this.getContext();
if (context) {
context.emit("issueOrder", orderData, (response) => {
if (response.success) {
// Clear the cart
window.cart = [];
window.dispatchEvent(new CustomEvent("cart"));
// Reset state and navigate to orders tab
this.setState({
isCheckingOut: false,
cartItems: [],
isCompletingOrder: false,
completionError: null,
});
// Call success callback if provided
if (this.onOrderSuccess) {
this.onOrderSuccess();
}
} else {
this.setState({
isCompletingOrder: false,
completionError: response.error || "Failed to complete order. Please try again.",
});
}
});
} else {
console.error("Socket context not available");
this.setState({
isCompletingOrder: false,
completionError: "Cannot connect to server. Please try again later.",
});
}
}
// Create Stripe payment intent
createStripeIntent(totalAmount, loadStripeComponent) {
const context = this.getContext();
if (context) {
context.emit(
"createStripeIntent",
{ amount: totalAmount },
(response) => {
if (response.success) {
loadStripeComponent(response.client_secret);
} else {
console.error("Error:", response.error);
this.setState({
isCompletingOrder: false,
completionError: response.error || "Failed to create Stripe payment intent. Please try again.",
});
}
}
);
} else {
console.error("Socket context not available");
this.setState({
isCompletingOrder: false,
completionError: "Cannot connect to server. Please try again later.",
});
}
}
// Calculate delivery cost
getDeliveryCost() {
const { deliveryMethod, paymentMethod } = this.getState();
let cost = 0;
switch (deliveryMethod) {
case "DHL":
cost = 6.99;
break;
case "DPD":
cost = 4.9;
break;
case "Sperrgut":
cost = 28.99;
break;
case "Abholung":
cost = 0;
break;
default:
cost = 6.99;
}
// Add onDelivery surcharge if selected
if (paymentMethod === "onDelivery") {
cost += 8.99;
}
return cost;
}
// Helper method to get current state (to be overridden by component)
getState() {
throw new Error("getState method must be implemented by the component");
}
// Set callback for order success
setOrderSuccessCallback(callback) {
this.onOrderSuccess = callback;
}
}
export default OrderProcessingService;

View File

@@ -0,0 +1,106 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
const OrderSummary = ({ deliveryCost, cartItems = [] }) => {
const currencyFormatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
});
// Calculate VAT breakdown for cart items (similar to CartDropdown)
const cartVatCalculations = cartItems.reduce((acc, item) => {
const totalItemPrice = item.price * item.quantity;
const netPrice = totalItemPrice / (1 + item.vat / 100);
const vatAmount = totalItemPrice - netPrice;
acc.totalGross += totalItemPrice;
acc.totalNet += netPrice;
if (item.vat === 7) {
acc.vat7 += vatAmount;
} else if (item.vat === 19) {
acc.vat19 += vatAmount;
}
return acc;
}, { totalGross: 0, totalNet: 0, vat7: 0, vat19: 0 });
// Calculate shipping VAT (19% VAT for shipping costs)
const shippingNetPrice = deliveryCost / (1 + 19 / 100);
const shippingVat = deliveryCost - shippingNetPrice;
// Combine totals - add shipping VAT to the 19% VAT total
const totalVat7 = cartVatCalculations.vat7;
const totalVat19 = cartVatCalculations.vat19 + shippingVat;
const totalGross = cartVatCalculations.totalGross + deliveryCost;
return (
<Box sx={{ my: 3, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
<Typography variant="h6" gutterBottom>
Bestellübersicht
</Typography>
<Table size="small">
<TableBody>
<TableRow>
<TableCell>Waren (netto):</TableCell>
<TableCell align="right">
{currencyFormatter.format(cartVatCalculations.totalNet)}
</TableCell>
</TableRow>
{deliveryCost > 0 && (
<TableRow>
<TableCell>Versandkosten (netto):</TableCell>
<TableCell align="right">
{currencyFormatter.format(shippingNetPrice)}
</TableCell>
</TableRow>
)}
{totalVat7 > 0 && (
<TableRow>
<TableCell>7% Mehrwertsteuer:</TableCell>
<TableCell align="right">
{currencyFormatter.format(totalVat7)}
</TableCell>
</TableRow>
)}
{totalVat19 > 0 && (
<TableRow>
<TableCell>19% Mehrwertsteuer (inkl. Versand):</TableCell>
<TableCell align="right">
{currencyFormatter.format(totalVat19)}
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Gesamtsumme Waren:</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
{currencyFormatter.format(cartVatCalculations.totalGross)}
</TableCell>
</TableRow>
{deliveryCost > 0 && (
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Versandkosten:</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>
{currencyFormatter.format(deliveryCost)}
</TableCell>
</TableRow>
)}
<TableRow sx={{ borderTop: '1px solid #e0e0e0' }}>
<TableCell sx={{ fontWeight: 'bold', pt: 2 }}>Gesamtsumme:</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold', pt: 2 }}>
{currencyFormatter.format(totalGross)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</Box>
);
};
export default OrderSummary;

View File

@@ -0,0 +1,246 @@
import React, { useState, useEffect, useContext, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Paper,
Alert,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Tooltip,
CircularProgress,
Typography,
} from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import SocketContext from "../../contexts/SocketContext.js";
import OrderDetailsDialog from "./OrderDetailsDialog.js";
// Constants
const statusTranslations = {
new: "in Bearbeitung",
pending: "Neu",
processing: "in Bearbeitung",
cancelled: "Storniert",
shipped: "Verschickt",
delivered: "Geliefert",
};
const statusEmojis = {
"in Bearbeitung": "⚙️",
pending: "⏳",
processing: "🔄",
cancelled: "❌",
Verschickt: "🚚",
Geliefert: "✅",
Storniert: "❌",
Retoure: "↩️",
"Teil Retoure": "↪️",
"Teil geliefert": "⚡",
};
const statusColors = {
"in Bearbeitung": "#ed6c02", // orange
pending: "#ff9800", // orange for pending
processing: "#2196f3", // blue for processing
cancelled: "#d32f2f", // red for cancelled
Verschickt: "#2e7d32", // green
Geliefert: "#2e7d32", // green
Storniert: "#d32f2f", // red
Retoure: "#9c27b0", // purple
"Teil Retoure": "#9c27b0", // purple
"Teil geliefert": "#009688", // teal
};
const currencyFormatter = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
});
// Orders Tab Content Component
const OrdersTab = ({ orderIdFromHash }) => {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedOrder, setSelectedOrder] = useState(null);
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
const socket = useContext(SocketContext);
const navigate = useNavigate();
const handleViewDetails = useCallback(
(orderId) => {
const orderToView = orders.find((order) => order.orderId === orderId);
if (orderToView) {
setSelectedOrder(orderToView);
setIsDetailsDialogOpen(true);
}
},
[orders]
);
const fetchOrders = useCallback(() => {
if (socket && socket.connected) {
setLoading(true);
setError(null);
socket.emit("getOrders", (response) => {
if (response.success) {
setOrders(response.orders);
} else {
setError(response.error || "Failed to fetch orders.");
}
setLoading(false);
});
} else {
// Socket not connected yet, but don't show error immediately on first load
console.log("Socket not connected yet, waiting for connection to fetch orders");
setLoading(false); // Stop loading when socket is not connected
}
}, [socket]);
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
// Monitor socket connection changes
useEffect(() => {
if (socket && socket.connected && orders.length === 0) {
// Socket just connected and we don't have orders yet, fetch them
fetchOrders();
}
}, [socket, socket?.connected, orders.length, fetchOrders]);
useEffect(() => {
if (orderIdFromHash && orders.length > 0) {
handleViewDetails(orderIdFromHash);
}
}, [orderIdFromHash, orders, handleViewDetails]);
const getStatusDisplay = (status) => {
return statusTranslations[status] || status;
};
const getStatusEmoji = (status) => {
return statusEmojis[status] || "❓";
};
const getStatusColor = (status) => {
return statusColors[status] || "#757575"; // default gray
};
const handleCloseDetailsDialog = () => {
setIsDetailsDialogOpen(false);
setSelectedOrder(null);
navigate("/profile", { replace: true });
};
if (loading) {
return (
<Box sx={{ p: 3, display: "flex", justifyContent: "center" }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="error">{error}</Alert>
</Box>
);
}
return (
<Box sx={{ p: 3 }}>
{orders.length > 0 ? (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Bestellnummer</TableCell>
<TableCell>Datum</TableCell>
<TableCell>Status</TableCell>
<TableCell>Artikel</TableCell>
<TableCell align="right">Summe</TableCell>
<TableCell align="center">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{orders.map((order) => {
const displayStatus = getStatusDisplay(order.status);
const subtotal = order.items.reduce(
(acc, item) => acc + item.price * item.quantity_ordered,
0
);
const total = subtotal + order.delivery_cost;
return (
<TableRow key={order.orderId} hover>
<TableCell>{order.orderId}</TableCell>
<TableCell>
{new Date(order.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "8px",
color: getStatusColor(displayStatus),
}}
>
<span style={{ fontSize: "1.2rem" }}>
{getStatusEmoji(displayStatus)}
</span>
<Typography
variant="body2"
component="span"
sx={{ fontWeight: "medium" }}
>
{displayStatus}
</Typography>
</Box>
</TableCell>
<TableCell>
{order.items.reduce(
(acc, item) => acc + item.quantity_ordered,
0
)}
</TableCell>
<TableCell align="right">
{currencyFormatter.format(total)}
</TableCell>
<TableCell align="center">
<Tooltip title="Details anzeigen">
<IconButton
size="small"
color="primary"
onClick={() => handleViewDetails(order.orderId)}
>
<SearchIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
) : (
<Alert severity="info">
Sie haben noch keine Bestellungen aufgegeben.
</Alert>
)}
<OrderDetailsDialog
open={isDetailsDialogOpen}
onClose={handleCloseDetailsDialog}
order={selectedOrder}
/>
</Box>
);
};
export default OrdersTab;

View File

@@ -0,0 +1,97 @@
import React, { Component } from "react";
import { Box, Typography, Button } from "@mui/material";
class PaymentConfirmationDialog extends Component {
render() {
const {
paymentCompletionData,
isCompletingOrder,
completionError,
orderCompleted,
onContinueShopping,
onViewOrders,
} = this.props;
if (!paymentCompletionData) return null;
return (
<Box sx={{
mb: 3,
p: 3,
border: '2px solid',
borderColor: paymentCompletionData.isSuccessful ? '#2e7d32' : '#d32f2f',
borderRadius: 2,
bgcolor: paymentCompletionData.isSuccessful ? '#e8f5e8' : '#ffebee'
}}>
<Typography variant="h5" sx={{
mb: 2,
color: paymentCompletionData.isSuccessful ? '#2e7d32' : '#d32f2f',
fontWeight: 'bold'
}}>
{paymentCompletionData.isSuccessful ? 'Zahlung erfolgreich!' : 'Zahlung fehlgeschlagen'}
</Typography>
{paymentCompletionData.isSuccessful ? (
<>
{orderCompleted ? (
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
🎉 Ihre Bestellung wurde erfolgreich abgeschlossen! Sie können jetzt Ihre Bestellungen einsehen.
</Typography>
) : (
<Typography variant="body1" sx={{ mt: 2, color: '#2e7d32' }}>
Ihre Zahlung wurde erfolgreich verarbeitet. Die Bestellung wird automatisch abgeschlossen.
</Typography>
)}
</>
) : (
<Typography variant="body1" sx={{ mt: 2, color: '#d32f2f' }}>
Ihre Zahlung konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut oder wählen Sie eine andere Zahlungsmethode.
</Typography>
)}
{isCompletingOrder && (
<Typography variant="body2" sx={{ mt: 2, color: '#2e7d32', p: 2, bgcolor: '#e8f5e8', borderRadius: 1 }}>
Bestellung wird abgeschlossen...
</Typography>
)}
{completionError && (
<Typography variant="body2" sx={{ mt: 2, color: '#d32f2f', p: 2, bgcolor: '#ffcdd2', borderRadius: 1 }}>
{completionError}
</Typography>
)}
{orderCompleted && (
<Box sx={{ mt: 3, display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Button
onClick={onContinueShopping}
variant="outlined"
sx={{
color: '#2e7d32',
borderColor: '#2e7d32',
'&:hover': {
backgroundColor: 'rgba(46, 125, 50, 0.04)',
borderColor: '#1b5e20'
}
}}
>
Weiter einkaufen
</Button>
<Button
onClick={onViewOrders}
variant="contained"
sx={{
bgcolor: '#2e7d32',
'&:hover': { bgcolor: '#1b5e20' }
}}
>
Zu meinen Bestellungen
</Button>
</Box>
)}
</Box>
);
}
}
export default PaymentConfirmationDialog;

View File

@@ -0,0 +1,178 @@
import React, { useEffect, useCallback } from "react";
import { Box, Typography, Radio } from "@mui/material";
const PaymentMethodSelector = ({ paymentMethod, onChange, deliveryMethod, onDeliveryMethodChange, cartItems = [], deliveryCost = 0 }) => {
// Calculate total amount
const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
const totalAmount = subtotal + deliveryCost;
// Handle payment method changes with automatic delivery method adjustment
const handlePaymentMethodChange = useCallback((event) => {
const selectedPaymentMethod = event.target.value;
// If "Zahlung in der Filiale" is selected, force delivery method to "Abholung"
if (selectedPaymentMethod === "cash" && deliveryMethod !== "Abholung") {
if (onDeliveryMethodChange) {
onDeliveryMethodChange({ target: { value: "Abholung" } });
}
}
onChange(event);
}, [deliveryMethod, onDeliveryMethodChange, onChange]);
// Handle delivery method changes - auto-switch to stripe when DHL/DPD is selected
useEffect(() => {
if ((deliveryMethod === "DHL" || deliveryMethod === "DPD") && paymentMethod === "cash") {
handlePaymentMethodChange({ target: { value: "stripe" } });
}
}, [deliveryMethod, paymentMethod, handlePaymentMethodChange]);
// Auto-switch to cash when total amount is 0
useEffect(() => {
if (totalAmount === 0 && paymentMethod !== "cash") {
handlePaymentMethodChange({ target: { value: "cash" } });
}
}, [totalAmount, paymentMethod, handlePaymentMethodChange]);
const paymentOptions = [
{
id: "wire",
name: "Überweisung",
description: "Bezahlen Sie per Banküberweisung",
disabled: totalAmount === 0,
},
{
id: "stripe",
name: "Karte oder Sofortüberweisung",
description: totalAmount < 0.50 && totalAmount > 0
? "Bezahlen Sie per Karte oder Sofortüberweisung (Mindestbetrag: 0,50 €)"
: "Bezahlen Sie per Karte oder Sofortüberweisung",
disabled: totalAmount < 0.50 || (deliveryMethod !== "DHL" && deliveryMethod !== "DPD" && deliveryMethod !== "Abholung"),
icons: [
"/assets/images/giropay.png",
"/assets/images/maestro.png",
"/assets/images/mastercard.png",
"/assets/images/visa_electron.png",
],
},
{
id: "onDelivery",
name: "Nachnahme",
description: "Bezahlen Sie bei Lieferung (8,99 € Aufschlag)",
disabled: totalAmount === 0 || deliveryMethod !== "DHL",
icons: ["/assets/images/cash.png"],
},
{
id: "cash",
name: "Zahlung in der Filiale",
description: "Bei Abholung bezahlen",
disabled: false, // Always enabled
icons: ["/assets/images/cash.png"],
},
];
return (
<>
<Typography variant="h6" gutterBottom>
Zahlungsart wählen
</Typography>
<Box sx={{ mb: 3 }}>
{paymentOptions.map((option, index) => (
<Box
key={option.id}
sx={{
display: "flex",
alignItems: "center",
mb: index < paymentOptions.length - 1 ? 1 : 0,
p: 1,
border: "1px solid #e0e0e0",
borderRadius: 1,
cursor: option.disabled ? "not-allowed" : "pointer",
opacity: option.disabled ? 0.6 : 1,
transition: "all 0.2s ease-in-out",
"&:hover": !option.disabled
? {
backgroundColor: "#f5f5f5",
borderColor: "#2e7d32",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
}
: {},
...(paymentMethod === option.id &&
!option.disabled && {
backgroundColor: "#e8f5e8",
borderColor: "#2e7d32",
}),
}}
onClick={
!option.disabled
? () => handlePaymentMethodChange({ target: { value: option.id } })
: undefined
}
>
<Radio
id={option.id}
name="paymentMethod"
value={option.id}
checked={paymentMethod === option.id}
onChange={handlePaymentMethodChange}
disabled={option.disabled}
sx={{ cursor: option.disabled ? "not-allowed" : "pointer" }}
/>
<Box sx={{ ml: 2, flex: 1 }}>
<label
htmlFor={option.id}
style={{
cursor: option.disabled ? "not-allowed" : "pointer",
color: option.disabled ? "#999" : "inherit",
}}
>
<Typography
variant="body1"
sx={{ color: option.disabled ? "#999" : "inherit" }}
>
{option.name}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ color: option.disabled ? "#ccc" : "text.secondary" }}
>
{option.description}
</Typography>
</label>
</Box>
{option.icons && (
<Box
sx={{
display: "flex",
gap: 1,
alignItems: "center",
flexWrap: "wrap",
ml: 2
}}
>
{option.icons.map((iconPath, iconIndex) => (
<img
key={iconIndex}
src={iconPath}
alt={`Payment method ${iconIndex + 1}`}
style={{
height: "24px",
width: "auto",
opacity: option.disabled ? 0.5 : 1,
objectFit: "contain",
}}
/>
))}
</Box>
)}
</Box>
))}
</Box>
</>
);
};
export default PaymentMethodSelector;

View File

@@ -0,0 +1,426 @@
import React, { Component } from 'react';
import {
Box,
Paper,
Typography,
TextField,
Button,
Alert,
CircularProgress,
Divider,
IconButton,
Snackbar
} from '@mui/material';
import { ContentCopy } from '@mui/icons-material';
class SettingsTab extends Component {
constructor(props) {
super(props);
this.state = {
currentPassword: '',
newPassword: '',
confirmPassword: '',
password: '',
newEmail: '',
passwordError: '',
passwordSuccess: '',
emailError: '',
emailSuccess: '',
loading: false,
// API Key management state
hasApiKey: false,
apiKey: '',
apiKeyDisplay: '',
apiKeyError: '',
apiKeySuccess: '',
loadingApiKey: false,
copySnackbarOpen: false
};
}
componentDidMount() {
// Load user data
const storedUser = sessionStorage.getItem('user');
if (storedUser) {
try {
const user = JSON.parse(storedUser);
this.setState({ newEmail: user.email || '' });
// Check if user has an API key
this.props.socket.emit('isApiKey', (response) => {
if (response.success && response.hasApiKey) {
this.setState({
hasApiKey: true,
apiKeyDisplay: '************'
});
}
});
} catch (error) {
console.error('Error loading user data:', error);
}
}
}
handleUpdatePassword = (e) => {
e.preventDefault();
// Reset states
this.setState({
passwordError: '',
passwordSuccess: ''
});
// Validation
if (!this.state.currentPassword || !this.state.newPassword || !this.state.confirmPassword) {
this.setState({ passwordError: 'Bitte füllen Sie alle Felder aus' });
return;
}
if (this.state.newPassword !== this.state.confirmPassword) {
this.setState({ passwordError: 'Die neuen Passwörter stimmen nicht überein' });
return;
}
if (this.state.newPassword.length < 8) {
this.setState({ passwordError: 'Das neue Passwort muss mindestens 8 Zeichen lang sein' });
return;
}
this.setState({ loading: true });
// Call socket.io endpoint to update password
this.props.socket.emit('updatePassword',
{ oldPassword: this.state.currentPassword, newPassword: this.state.newPassword },
(response) => {
this.setState({ loading: false });
if (response.success) {
this.setState({
passwordSuccess: 'Passwort erfolgreich aktualisiert',
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
} else {
this.setState({
passwordError: response.message || 'Fehler beim Aktualisieren des Passworts'
});
}
}
);
};
handleUpdateEmail = (e) => {
e.preventDefault();
// Reset states
this.setState({
emailError: '',
emailSuccess: ''
});
// Validation
if (!this.state.password || !this.state.newEmail) {
this.setState({ emailError: 'Bitte füllen Sie alle Felder aus' });
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.state.newEmail)) {
this.setState({ emailError: 'Bitte geben Sie eine gültige E-Mail-Adresse ein' });
return;
}
this.setState({ loading: true });
// Call socket.io endpoint to update email
this.props.socket.emit('updateEmail',
{ password: this.state.password, email: this.state.newEmail },
(response) => {
this.setState({ loading: false });
if (response.success) {
this.setState({
emailSuccess: 'E-Mail-Adresse erfolgreich aktualisiert',
password: ''
});
// Update user in sessionStorage
try {
const storedUser = sessionStorage.getItem('user');
if (storedUser) {
const user = JSON.parse(storedUser);
user.email = this.state.newEmail;
sessionStorage.setItem('user', JSON.stringify(user));
}
} catch (error) {
console.error('Error updating user in sessionStorage:', error);
}
} else {
this.setState({
emailError: response.message || 'Fehler beim Aktualisieren der E-Mail-Adresse'
});
}
}
);
};
handleGenerateApiKey = () => {
this.setState({
apiKeyError: '',
apiKeySuccess: '',
loadingApiKey: true
});
const storedUser = sessionStorage.getItem('user');
if (!storedUser) {
this.setState({
apiKeyError: 'Benutzer nicht gefunden',
loadingApiKey: false
});
return;
}
try {
const user = JSON.parse(storedUser);
this.props.socket.emit('createApiKey', user.id, (response) => {
this.setState({ loadingApiKey: false });
if (response.success) {
this.setState({
hasApiKey: true,
apiKey: response.apiKey,
apiKeyDisplay: response.apiKey,
apiKeySuccess: response.message || 'API-Schlüssel erfolgreich generiert'
});
// After 10 seconds, hide the actual key and show asterisks
setTimeout(() => {
this.setState({ apiKeyDisplay: '************' });
}, 10000);
} else {
this.setState({
apiKeyError: response.message || 'Fehler beim Generieren des API-Schlüssels'
});
}
});
} catch (error) {
console.error('Error generating API key:', error);
this.setState({
apiKeyError: 'Fehler beim Generieren des API-Schlüssels',
loadingApiKey: false
});
}
};
handleCopyToClipboard = () => {
navigator.clipboard.writeText(this.state.apiKey).then(() => {
this.setState({ copySnackbarOpen: true });
}).catch(err => {
console.error('Failed to copy to clipboard:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = this.state.apiKey;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
this.setState({ copySnackbarOpen: true });
});
};
handleCloseSnackbar = () => {
this.setState({ copySnackbarOpen: false });
};
render() {
return (
<Box sx={{ p: 3 }}>
<Paper sx={{ p: 3}}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
Passwort ändern
</Typography>
{this.state.passwordError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.passwordError}</Alert>}
{this.state.passwordSuccess && <Alert severity="success" sx={{ mb: 2 }}>{this.state.passwordSuccess}</Alert>}
<Box component="form" onSubmit={this.handleUpdatePassword}>
<TextField
margin="normal"
label="Aktuelles Passwort"
type="password"
fullWidth
value={this.state.currentPassword}
onChange={(e) => this.setState({ currentPassword: e.target.value })}
disabled={this.state.loading}
/>
<TextField
margin="normal"
label="Neues Passwort"
type="password"
fullWidth
value={this.state.newPassword}
onChange={(e) => this.setState({ newPassword: e.target.value })}
disabled={this.state.loading}
/>
<TextField
margin="normal"
label="Neues Passwort bestätigen"
type="password"
fullWidth
value={this.state.confirmPassword}
onChange={(e) => this.setState({ confirmPassword: e.target.value })}
disabled={this.state.loading}
/>
<Button
type="submit"
variant="contained"
color="primary"
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
disabled={this.state.loading}
>
{this.state.loading ? <CircularProgress size={24} /> : 'Passwort aktualisieren'}
</Button>
</Box>
</Paper>
<Divider sx={{ my: 4 }} />
<Paper sx={{ p: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
E-Mail-Adresse ändern
</Typography>
{this.state.emailError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.emailError}</Alert>}
{this.state.emailSuccess && <Alert severity="success" sx={{ mb: 2 }}>{this.state.emailSuccess}</Alert>}
<Box component="form" onSubmit={this.handleUpdateEmail}>
<TextField
margin="normal"
label="Passwort"
type="password"
fullWidth
value={this.state.password}
onChange={(e) => this.setState({ password: e.target.value })}
disabled={this.state.loading}
/>
<TextField
margin="normal"
label="Neue E-Mail-Adresse"
type="email"
fullWidth
value={this.state.newEmail}
onChange={(e) => this.setState({ newEmail: e.target.value })}
disabled={this.state.loading}
/>
<Button
type="submit"
variant="contained"
color="primary"
sx={{ mt: 2, bgcolor: '#2e7d32', '&:hover': { bgcolor: '#1b5e20' } }}
disabled={this.state.loading}
>
{this.state.loading ? <CircularProgress size={24} /> : 'E-Mail aktualisieren'}
</Button>
</Box>
</Paper>
<Divider sx={{ my: 4 }} />
<Paper sx={{ p: 3 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
API-Schlüssel
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Verwenden Sie Ihren API-Schlüssel für die Integration mit externen Anwendungen.
</Typography>
{this.state.apiKeyError && <Alert severity="error" sx={{ mb: 2 }}>{this.state.apiKeyError}</Alert>}
{this.state.apiKeySuccess && (
<Alert severity="success" sx={{ mb: 2 }}>
{this.state.apiKeySuccess}
{this.state.apiKey && this.state.apiKeyDisplay !== '************' && (
<Typography variant="body2" sx={{ mt: 1 }}>
Speichern Sie diesen Schlüssel sicher. Er wird aus Sicherheitsgründen in 10 Sekunden ausgeblendet.
</Typography>
)}
</Alert>
)}
<Typography variant="body2" sx={{ mb: 2 }}>
API-Dokumentation: {' '}
<a
href={`${window.location.protocol}//${window.location.host}/api/`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#2e7d32' }}
>
{`${window.location.protocol}//${window.location.host}/api/`}
</a>
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 2 }}>
<TextField
label="API-Schlüssel"
value={this.state.apiKeyDisplay}
disabled
fullWidth
sx={{
'& .MuiInputBase-input.Mui-disabled': {
WebkitTextFillColor: this.state.apiKeyDisplay === '************' ? '#666' : '#000',
}
}}
/>
{this.state.apiKeyDisplay !== '************' && this.state.apiKey && (
<IconButton
onClick={this.handleCopyToClipboard}
sx={{
color: '#2e7d32',
'&:hover': { bgcolor: 'rgba(46, 125, 50, 0.1)' }
}}
title="In Zwischenablage kopieren"
>
<ContentCopy />
</IconButton>
)}
<Button
variant="contained"
color="primary"
onClick={this.handleGenerateApiKey}
disabled={this.state.loadingApiKey}
sx={{
minWidth: 120,
bgcolor: '#2e7d32',
'&:hover': { bgcolor: '#1b5e20' }
}}
>
{this.state.loadingApiKey ? (
<CircularProgress size={24} />
) : (
this.state.hasApiKey ? 'Regenerieren' : 'Generieren'
)}
</Button>
</Box>
</Paper>
<Snackbar
open={this.state.copySnackbarOpen}
autoHideDuration={3000}
onClose={this.handleCloseSnackbar}
message="API-Schlüssel in Zwischenablage kopiert"
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</Box>
);
}
}
export default SettingsTab;