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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
129
src/utils/articlePush.js
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user