feat: Implement push notification functionality in AddToCartButton, including subscription management and UI updates, and enhance translations for related error messages across multiple locales

This commit is contained in:
sebseb7
2026-03-25 07:57:34 +01:00
parent 0ce8ce3626
commit af6893b5b0
4 changed files with 349 additions and 32 deletions

View File

@@ -11,7 +11,20 @@ import RemoveIcon from "@mui/icons-material/Remove";
import ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
import DeleteIcon from "@mui/icons-material/Delete";
import NotificationsIcon from "@mui/icons-material/Notifications";
import NotificationsActiveIcon from "@mui/icons-material/NotificationsActive";
import CircularProgress from "@mui/material/CircularProgress";
import { withI18n } from "../i18n/withTranslation.js";
import {
isPushApiSupported,
fetchPushConfiguration,
registerPushServiceWorker,
ensurePushSubscription,
articlePushStatus,
articlePushSubscribe,
articlePushUnsubscribe,
parseSubscribedStatus,
parseSuccess,
} from "../utils/articlePush.js";
if (!Array.isArray(window.cart)) window.cart = [];
@@ -25,9 +38,134 @@ class AddToCartButton extends Component {
: 0,
isEditing: false,
editValue: "",
pushInteractive: false,
pushSubscribed: false,
pushBusy: false,
pushError: null,
};
}
kArtikelNumber = () => {
const { id } = this.props;
const n = typeof id === "number" ? id : parseInt(id, 10);
return Number.isFinite(n) && n > 0 ? n : null;
};
refreshIncomingPushStatus = async () => {
const { available, incoming } = this.props;
if (available || !incoming) {
this.setState({
pushInteractive: false,
pushSubscribed: false,
pushError: null,
});
return;
}
const kArtikel = this.kArtikelNumber();
if (!kArtikel || !isPushApiSupported()) {
this.setState({ pushInteractive: false });
return;
}
try {
const cfg = await fetchPushConfiguration();
if (!cfg.configured || !cfg.publicKey) {
this.setState({ pushInteractive: false });
return;
}
await registerPushServiceWorker();
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
this.setState({
pushInteractive: true,
pushSubscribed: false,
pushError: null,
});
return;
}
const statusData = await articlePushStatus(kArtikel, subscription.endpoint);
this.setState({
pushInteractive: true,
pushSubscribed: parseSubscribedStatus(statusData),
pushError: null,
});
} catch (e) {
console.warn("AddToCartButton: incoming push init failed", e);
this.setState({ pushInteractive: false });
}
};
handleIncomingPushClick = async () => {
if (!this.state.pushInteractive || this.state.pushBusy) return;
const kArtikel = this.kArtikelNumber();
if (!kArtikel) return;
const t = this.props.t;
this.setState({ pushBusy: true, pushError: null });
try {
if (this.state.pushSubscribed) {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
this.setState({ pushSubscribed: false, pushBusy: false });
return;
}
const res = await articlePushUnsubscribe(subscription.endpoint);
if (parseSuccess(res)) {
this.setState({ pushSubscribed: false });
} else {
this.setState({
pushError:
res?.message ||
res?.error ||
(t ? t("productDialogs.pushNotifyError") : ""),
});
}
} else {
const perm = await Notification.requestPermission();
if (perm !== "granted") {
this.setState({
pushError: t
? t("productDialogs.pushNotifyPermissionDenied")
: "",
pushBusy: false,
});
return;
}
const cfg = await fetchPushConfiguration();
if (!cfg.configured || !cfg.publicKey) {
this.setState({
pushError: t
? t("productDialogs.pushNotifyServerDisabled")
: "",
pushBusy: false,
});
return;
}
await registerPushServiceWorker();
const subscription = await ensurePushSubscription(cfg.publicKey);
const res = await articlePushSubscribe(kArtikel, subscription);
if (parseSuccess(res)) {
this.setState({ pushSubscribed: true });
} else {
this.setState({
pushError:
res?.message ||
res?.error ||
(t ? t("productDialogs.pushNotifyError") : ""),
});
}
}
} catch (e) {
console.error("AddToCartButton: incoming push", e);
this.setState({
pushError:
e.message || (t ? t("productDialogs.pushNotifyError") : ""),
});
} finally {
this.setState({ pushBusy: false });
}
};
componentDidMount() {
this.cart = () => {
if (!Array.isArray(window.cart)) window.cart = [];
@@ -37,6 +175,17 @@ class AddToCartButton extends Component {
this.setState({ quantity: newQuantity });
};
window.addEventListener("cart", this.cart);
this.refreshIncomingPushStatus();
}
componentDidUpdate(prevProps) {
if (
prevProps.available !== this.props.available ||
prevProps.incoming !== this.props.incoming ||
prevProps.id !== this.props.id
) {
this.refreshIncomingPushStatus();
}
}
componentWillUnmount() {
@@ -134,46 +283,77 @@ class AddToCartButton extends Component {
};
render() {
const { quantity, isEditing, editValue } = this.state;
const {
quantity,
isEditing,
editValue,
pushInteractive,
pushSubscribed,
pushBusy,
pushError,
} = this.state;
const { available, size, incoming, availableSupplier } = this.props;
// Button is disabled if product is not available
if (!available) {
if (incoming) {
const dateStr = new Date(incoming).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
});
const dateLabel = this.props.t
? this.props.t("cart.availableFrom", { date: dateStr })
: `Ab ${dateStr}`;
return (
<Button
fullWidth
variant="contained"
size={size || "medium"}
startIcon={<NotificationsIcon />}
sx={{
borderRadius: 2,
fontWeight: "bold",
backgroundColor: "#ffeb3b",
color: "#000000",
whiteSpace: "nowrap",
flexWrap: "nowrap",
"& .MuiButton-label": {
<Box sx={{ width: "100%" }}>
<Button
fullWidth
variant="contained"
size={size || "medium"}
onClick={pushInteractive ? this.handleIncomingPushClick : undefined}
disabled={pushInteractive && pushBusy}
startIcon={
pushBusy ? (
<CircularProgress size={18} sx={{ color: "inherit" }} />
) : pushSubscribed ? (
<NotificationsActiveIcon sx={{ color: "#2e7d32" }} />
) : (
<NotificationsIcon sx={{ color: "rgba(0,0,0,0.75)" }} />
)
}
sx={{
borderRadius: 2,
fontWeight: "bold",
backgroundColor: "#ffeb3b",
color: "#000000",
whiteSpace: "nowrap",
flexWrap: "nowrap",
},
"&:hover": {
backgroundColor: "#fdd835",
},
}}
>
{this.props.t ? this.props.t('cart.availableFrom', {
date: new Date(incoming).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})
}) : `Ab ${new Date(incoming).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})}`}
</Button>
"& .MuiButton-label": {
whiteSpace: "nowrap",
flexWrap: "nowrap",
},
"&:hover": {
backgroundColor: "#fdd835",
},
...(pushInteractive && {
cursor: "pointer",
}),
}}
>
{dateLabel}
</Button>
{pushError && (
<Typography
variant="caption"
color="error"
sx={{ display: "block", mt: 0.5, textAlign: "center" }}
>
{pushError}
</Typography>
)}
</Box>
);
}