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 ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import NotificationsIcon from "@mui/icons-material/Notifications"; 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 { 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 = []; if (!Array.isArray(window.cart)) window.cart = [];
@@ -25,9 +38,134 @@ class AddToCartButton extends Component {
: 0, : 0,
isEditing: false, isEditing: false,
editValue: "", 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() { componentDidMount() {
this.cart = () => { this.cart = () => {
if (!Array.isArray(window.cart)) window.cart = []; if (!Array.isArray(window.cart)) window.cart = [];
@@ -37,6 +175,17 @@ class AddToCartButton extends Component {
this.setState({ quantity: newQuantity }); this.setState({ quantity: newQuantity });
}; };
window.addEventListener("cart", this.cart); 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() { componentWillUnmount() {
@@ -134,46 +283,77 @@ class AddToCartButton extends Component {
}; };
render() { render() {
const { quantity, isEditing, editValue } = this.state; const {
quantity,
isEditing,
editValue,
pushInteractive,
pushSubscribed,
pushBusy,
pushError,
} = this.state;
const { available, size, incoming, availableSupplier } = this.props; const { available, size, incoming, availableSupplier } = this.props;
// Button is disabled if product is not available // Button is disabled if product is not available
if (!available) { if (!available) {
if (incoming) { 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 ( return (
<Button <Box sx={{ width: "100%" }}>
fullWidth <Button
variant="contained" fullWidth
size={size || "medium"} variant="contained"
startIcon={<NotificationsIcon />} size={size || "medium"}
sx={{ onClick={pushInteractive ? this.handleIncomingPushClick : undefined}
borderRadius: 2, disabled={pushInteractive && pushBusy}
fontWeight: "bold", startIcon={
backgroundColor: "#ffeb3b", pushBusy ? (
color: "#000000", <CircularProgress size={18} sx={{ color: "inherit" }} />
whiteSpace: "nowrap", ) : pushSubscribed ? (
flexWrap: "nowrap", <NotificationsActiveIcon sx={{ color: "#2e7d32" }} />
"& .MuiButton-label": { ) : (
<NotificationsIcon sx={{ color: "rgba(0,0,0,0.75)" }} />
)
}
sx={{
borderRadius: 2,
fontWeight: "bold",
backgroundColor: "#ffeb3b",
color: "#000000",
whiteSpace: "nowrap", whiteSpace: "nowrap",
flexWrap: "nowrap", flexWrap: "nowrap",
}, "& .MuiButton-label": {
"&:hover": { whiteSpace: "nowrap",
backgroundColor: "#fdd835", flexWrap: "nowrap",
}, },
}} "&:hover": {
> backgroundColor: "#fdd835",
{this.props.t ? this.props.t('cart.availableFrom', { },
date: new Date(incoming).toLocaleDateString("de-DE", { ...(pushInteractive && {
year: "numeric", cursor: "pointer",
month: "long", }),
day: "numeric", }}
}) >
}) : `Ab ${new Date(incoming).toLocaleDateString("de-DE", { {dateLabel}
year: "numeric", </Button>
month: "long", {pushError && (
day: "numeric", <Typography
})}`} variant="caption"
</Button> color="error"
sx={{ display: "block", mt: 0.5, textAlign: "center" }}
>
{pushError}
</Typography>
)}
</Box>
); );
} }

View File

@@ -39,6 +39,10 @@ export default {
"messagePlaceholder": "Zusätzliche Informationen oder Fragen...", "messagePlaceholder": "Zusätzliche Informationen oder Fragen...",
"submitAvailability": "Verfügbarkeit anfragen", "submitAvailability": "Verfügbarkeit anfragen",
"pushNotifyPermissionDenied": "Benachrichtigungen wurden nicht erlaubt.",
"pushNotifyServerDisabled": "Push-Benachrichtigungen sind auf dem Server nicht aktiv.",
"pushNotifyError": "Benachrichtigung konnte nicht geändert werden.",
"photoUploadSelect": "Fotos auswählen", "photoUploadSelect": "Fotos auswählen",
"photoUploadErrorMaxFiles": "Maximal {{max}} Dateien erlaubt", "photoUploadErrorMaxFiles": "Maximal {{max}} Dateien erlaubt",
"photoUploadErrorFileType": "Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt", "photoUploadErrorFileType": "Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt",

View File

@@ -39,6 +39,10 @@ export default {
"messagePlaceholder": "Additional information or questions...", // Zusätzliche Informationen oder Fragen... "messagePlaceholder": "Additional information or questions...", // Zusätzliche Informationen oder Fragen...
"submitAvailability": "Request availability", // Verfügbarkeit anfragen "submitAvailability": "Request availability", // Verfügbarkeit anfragen
"pushNotifyPermissionDenied": "Notifications were not allowed.",
"pushNotifyServerDisabled": "Push notifications are not enabled on the server.",
"pushNotifyError": "Could not update notification settings.",
"photoUploadSelect": "Select photos", // Fotos auswählen "photoUploadSelect": "Select photos", // Fotos auswählen
"photoUploadErrorMaxFiles": "Maximum {{max}} files allowed", // Maximal {{max}} Dateien erlaubt "photoUploadErrorMaxFiles": "Maximum {{max}} files allowed", // Maximal {{max}} Dateien erlaubt
"photoUploadErrorFileType": "Only image files (JPEG, PNG, GIF, WebP) are allowed", // Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt "photoUploadErrorFileType": "Only image files (JPEG, PNG, GIF, WebP) are allowed", // Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt

129
src/utils/articlePush.js Normal file
View File

@@ -0,0 +1,129 @@
/**
* Web Push helpers for article notifications (VAPID + backend API).
*/
export const ARTICLE_PUSH_SW_PATH = "/api/check/push/service-worker";
export const ARTICLE_PUSH_VAPID_URL = "/api/check/push/vapid-public-key";
export function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export async function fetchPushConfiguration() {
const res = await fetch(ARTICLE_PUSH_VAPID_URL);
if (!res.ok) {
throw new Error(`VAPID config HTTP ${res.status}`);
}
return res.json();
}
export function isPushApiSupported() {
return (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
export async function registerPushServiceWorker() {
return navigator.serviceWorker.register(ARTICLE_PUSH_SW_PATH, {
scope: "/",
});
}
/**
* @param {string} vapidPublicKey
* @returns {Promise<PushSubscription>}
*/
export async function ensurePushSubscription(vapidPublicKey) {
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (subscription) {
return subscription;
}
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
});
}
/**
* @param {number} kArtikel
* @param {string} endpoint
*/
export async function articlePushStatus(kArtikel, endpoint) {
const res = await fetch("/api/article/push/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kArtikel, endpoint }),
});
const data = await res.json().catch(() => ({}));
return { ...data, httpOk: res.ok };
}
/**
* @param {number} kArtikel
* @param {PushSubscription} subscription
*/
export async function articlePushSubscribe(kArtikel, subscription) {
const subJson = subscription.toJSON();
const res = await fetch("/api/article/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kArtikel,
subscription: subJson,
}),
});
const data = await res.json().catch(() => ({}));
return { ...data, httpOk: res.ok };
}
/**
* @param {string} endpoint
*/
export async function articlePushUnsubscribe(endpoint) {
const res = await fetch("/api/article/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }),
});
const data = await res.json().catch(() => ({}));
return { ...data, httpOk: res.ok };
}
/**
* POST /api/article/push/status — backend returns:
* `{ "success": true, "registered": true | false }`
*/
export function parseSubscribedStatus(data) {
if (!data || typeof data !== "object") return false;
if (typeof data.registered === "boolean") {
return data.registered;
}
if (data.subscribed === true || data.isSubscribed === true) return true;
if (data.success === true && data.subscribed === true) return true;
if (data.status === "subscribed" || data.status === "active") return true;
return false;
}
/**
* POST /api/article/push/subscribe and .../unsubscribe — backend returns:
* `{ "success": true }`
*/
export function parseSuccess(data) {
if (!data || typeof data !== "object") return false;
if (data.success === true) return true;
if (data.success === false) return false;
if (data.httpOk === true) return true;
return false;
}