feat: Integrate Girocode functionality for wire payments in OrdersTab and LoginComponent, enhance user experience with pending payment notifications, and update translations across multiple locales

This commit is contained in:
sebseb7
2026-03-24 00:48:22 +01:00
parent f47fbc5c39
commit a9bf1aee5f
31 changed files with 969 additions and 93 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, Fragment } from "react";
import { useNavigate } from "react-router-dom";
import { withI18n } from "../../i18n/withTranslation.js";
import {
@@ -24,6 +24,12 @@ import {
import SearchIcon from "@mui/icons-material/Search";
import CancelIcon from "@mui/icons-material/Cancel";
import OrderDetailsDialog from "./OrderDetailsDialog.js";
import WireOrderGirocode from "./WireOrderGirocode.js";
import {
isWireGirocodeEligible,
hasPendingWirePaymentOrder,
WIRE_PAYMENT_PENDING_EVENT,
} from "../../utils/wireGirocodeEligibility.js";
// Constants
const getStatusTranslation = (status, t) => {
@@ -100,8 +106,20 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
window.socketManager.emit("getOrders", (response) => {
if (response.success) {
setOrders(response.orders);
window.dispatchEvent(
new CustomEvent(WIRE_PAYMENT_PENDING_EVENT, {
detail: {
pending: hasPendingWirePaymentOrder(response.orders),
},
})
);
} else {
setError(response.error || "Failed to fetch orders.");
window.dispatchEvent(
new CustomEvent(WIRE_PAYMENT_PENDING_EVENT, {
detail: { pending: false },
})
);
}
setLoading(false);
});
@@ -216,89 +234,107 @@ const OrdersTab = ({ orderIdFromHash, t }) => {
0
);
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(order.status),
}}
>
<span style={{ fontSize: "1.2rem" }}>
{getStatusEmoji(order.status)}
</span>
<Typography
variant="body2"
component="span"
sx={{ fontWeight: "medium" }}
<Fragment key={order.orderId}>
<TableRow hover>
<TableCell>{order.orderId}</TableCell>
<TableCell>
{new Date(order.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "8px",
color: getStatusColor(order.status),
}}
>
{displayStatus}
</Typography>
</Box>
{order.delivery_method === 'DHL' && order.trackingCode && (
<Box sx={{ mt: 0.5 }}>
<a
href={`https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${order.trackingCode}`}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '0.85rem', color: '#d40511' }}
<span style={{ fontSize: "1.2rem" }}>
{getStatusEmoji(order.status)}
</span>
<Typography
variant="body2"
component="span"
sx={{ fontWeight: "medium" }}
>
📦 {t ? t('orders.trackShipment') : 'Sendung verfolgen'}
</a>
{displayStatus}
</Typography>
</Box>
)}
</TableCell>
<TableCell>
{order.items
.filter(item => {
// Exclude delivery items - backend uses deliveryMethod ID as item name
const itemName = item.name || '';
return itemName !== 'DHL' &&
itemName !== 'DPD' &&
itemName !== 'Sperrgut' &&
itemName !== 'Abholung';
})
.reduce(
(acc, item) => acc + item.quantity_ordered,
0
{order.delivery_method === 'DHL' && order.trackingCode && (
<Box sx={{ mt: 0.5 }}>
<a
href={`https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${order.trackingCode}`}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '0.85rem', color: '#d40511' }}
>
📦 {t ? t('orders.trackShipment') : 'Sendung verfolgen'}
</a>
</Box>
)}
</TableCell>
<TableCell align="right">
{currencyFormatter.format(total)}
</TableCell>
<TableCell align="center">
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
<Tooltip title={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}>
<IconButton
size="small"
color="primary"
onClick={() => handleViewDetails(order.orderId)}
aria-label={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}
>
<SearchIcon />
</IconButton>
</Tooltip>
{isOrderCancelable(order) && (
<Tooltip title={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}>
</TableCell>
<TableCell>
{order.items
.filter(item => {
// Exclude delivery items - backend uses deliveryMethod ID as item name
const itemName = item.name || '';
return itemName !== 'DHL' &&
itemName !== 'DPD' &&
itemName !== 'Sperrgut' &&
itemName !== 'Abholung';
})
.reduce(
(acc, item) => acc + item.quantity_ordered,
0
)}
</TableCell>
<TableCell align="right">
{currencyFormatter.format(total)}
</TableCell>
<TableCell align="center">
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
<Tooltip title={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}>
<IconButton
size="small"
color="error"
onClick={() => handleCancelClick(order)}
aria-label={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}
color="primary"
onClick={() => handleViewDetails(order.orderId)}
aria-label={t ? t('orders.tooltips.viewDetails') : 'Details anzeigen'}
>
<CancelIcon />
<SearchIcon />
</IconButton>
</Tooltip>
)}
</Box>
</TableCell>
</TableRow>
{isOrderCancelable(order) && (
<Tooltip title={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}>
<IconButton
size="small"
color="error"
onClick={() => handleCancelClick(order)}
aria-label={t ? t('orders.tooltips.cancelOrder') : 'Bestellung stornieren'}
>
<CancelIcon />
</IconButton>
</Tooltip>
)}
</Box>
</TableCell>
</TableRow>
{isWireGirocodeEligible(order) && (
<TableRow>
<TableCell
colSpan={6}
sx={{
py: 2,
px: { xs: 1, sm: 2 },
verticalAlign: "top",
bgcolor: "action.hover",
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<WireOrderGirocode order={order} t={t} />
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>

View File

@@ -0,0 +1,177 @@
import React, { useEffect, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Box, Typography, CircularProgress } from "@mui/material";
import { buildWireGirocodeDataUrl } from "../../utils/wireGirocode.js";
import {
orderGrossTotal,
isWireGirocodeEligible,
} from "../../utils/wireGirocodeEligibility.js";
import { WIRE_GIROCODE_RECIPIENT } from "../../config/wireGirocodeRecipient.js";
/**
* Full-width row content: Girocode QR + bank details (pending + wire only).
*/
const WireOrderGirocode = ({ order, t }) => {
const { i18n } = useTranslation();
const [dataUrl, setDataUrl] = useState(null);
const [genError, setGenError] = useState(false);
const eligible = isWireGirocodeEligible(order);
const amount = useMemo(() => {
const raw = orderGrossTotal(order);
return Math.round(raw * 100) / 100;
}, [order]);
const amountFormatted = useMemo(() => {
const locale = (i18n.language || "de").replace("_", "-");
return new Intl.NumberFormat(locale, {
style: "currency",
currency: "EUR",
}).format(amount);
}, [amount, i18n.language]);
useEffect(() => {
if (!eligible || amount < 0.01) {
setDataUrl(null);
setGenError(false);
return;
}
let cancelled = false;
setDataUrl(null);
setGenError(false);
buildWireGirocodeDataUrl({
amount,
reference: order.orderId,
})
.then((url) => {
if (!cancelled) setDataUrl(url);
})
.catch(() => {
if (!cancelled) setGenError(true);
});
return () => {
cancelled = true;
};
}, [eligible, amount, order.orderId]);
if (!eligible) {
return null;
}
const hint = t
? t("orders.girocode.hint")
: "Mit Ihrer Banking-App scannen, um zu bezahlen.";
const alt = t
? t("orders.girocode.alt")
: "Girocode für die Überweisung";
const paymentPending = t
? t("orders.girocode.paymentPending")
: "Diese Bestellung wartet auf Ihre Überweisung.";
const payToAccount = t
? t("orders.girocode.payToAccount")
: "Bitte überweisen Sie den Betrag auf folgendes Konto:";
const holder = t
? t("orders.girocode.holder", { name: WIRE_GIROCODE_RECIPIENT.name })
: `Kontoinhaber: ${WIRE_GIROCODE_RECIPIENT.name}`;
const ibanLine = t
? t("orders.girocode.iban", { iban: WIRE_GIROCODE_RECIPIENT.iban })
: `IBAN: ${WIRE_GIROCODE_RECIPIENT.iban}`;
const bicLine = t
? t("orders.girocode.bic", { bic: WIRE_GIROCODE_RECIPIENT.bic })
: `BIC: ${WIRE_GIROCODE_RECIPIENT.bic}`;
const amountLine = t
? t("orders.girocode.amount", { amount: amountFormatted })
: `Betrag: ${amountFormatted}`;
const purposeLine = t
? t("orders.girocode.purpose", { orderId: order.orderId })
: `Verwendungszweck: ${order.orderId}`;
return (
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", sm: "row" },
gap: { xs: 2, sm: 3 },
alignItems: "flex-start",
}}
>
<Box sx={{ flexShrink: 0, width: { xs: "100%", sm: "auto" } }}>
<Typography
variant="caption"
component="div"
sx={{ display: "block", mb: 0.5, lineHeight: 1.35, maxWidth: 220 }}
>
{hint}
</Typography>
{!dataUrl && !genError && (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: 200,
height: 200,
}}
>
<CircularProgress size={28} />
</Box>
)}
{genError && (
<Typography variant="caption" color="text.secondary" component="div">
{t
? t("orders.girocode.error")
: "QR-Code konnte nicht erzeugt werden."}
</Typography>
)}
{dataUrl && (
<img
src={dataUrl}
width={200}
height={200}
alt={alt}
style={{ display: "block", borderRadius: 4 }}
/>
)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ mb: 1.5, lineHeight: 1.5 }}>
{paymentPending}
</Typography>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
{payToAccount}
</Typography>
<Typography variant="body2" component="div" sx={{ lineHeight: 1.6 }}>
{holder}
</Typography>
<Typography
variant="body2"
component="div"
sx={{
lineHeight: 1.6,
wordBreak: "break-all",
fontFamily: "ui-monospace, monospace",
}}
>
{ibanLine}
</Typography>
<Typography variant="body2" component="div" sx={{ lineHeight: 1.6 }}>
{bicLine}
</Typography>
<Typography variant="body2" component="div" sx={{ mt: 1.5, lineHeight: 1.6 }}>
{amountLine}
</Typography>
<Typography variant="body2" component="div" sx={{ lineHeight: 1.6 }}>
{purposeLine}
</Typography>
</Box>
</Box>
);
};
export default WireOrderGirocode;