diff --git a/src/components/AddToCartButton.js b/src/components/AddToCartButton.js
index 0210278..ed09909 100644
--- a/src/components/AddToCartButton.js
+++ b/src/components/AddToCartButton.js
@@ -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 (
- }
- sx={{
- borderRadius: 2,
- fontWeight: "bold",
- backgroundColor: "#ffeb3b",
- color: "#000000",
- whiteSpace: "nowrap",
- flexWrap: "nowrap",
- "& .MuiButton-label": {
+
+
+ ) : pushSubscribed ? (
+
+ ) : (
+
+ )
+ }
+ 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",
- })}`}
-
+ "& .MuiButton-label": {
+ whiteSpace: "nowrap",
+ flexWrap: "nowrap",
+ },
+ "&:hover": {
+ backgroundColor: "#fdd835",
+ },
+ ...(pushInteractive && {
+ cursor: "pointer",
+ }),
+ }}
+ >
+ {dateLabel}
+
+ {pushError && (
+
+ {pushError}
+
+ )}
+
);
}
diff --git a/src/i18n/locales/de/productDialogs.js b/src/i18n/locales/de/productDialogs.js
index 66b2293..0e500cd 100644
--- a/src/i18n/locales/de/productDialogs.js
+++ b/src/i18n/locales/de/productDialogs.js
@@ -39,6 +39,10 @@ export default {
"messagePlaceholder": "Zusätzliche Informationen oder Fragen...",
"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",
"photoUploadErrorMaxFiles": "Maximal {{max}} Dateien erlaubt",
"photoUploadErrorFileType": "Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt",
diff --git a/src/i18n/locales/en/productDialogs.js b/src/i18n/locales/en/productDialogs.js
index 435283c..7bcad0d 100644
--- a/src/i18n/locales/en/productDialogs.js
+++ b/src/i18n/locales/en/productDialogs.js
@@ -39,6 +39,10 @@ export default {
"messagePlaceholder": "Additional information or questions...", // Zusätzliche Informationen oder Fragen...
"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
"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
diff --git a/src/utils/articlePush.js b/src/utils/articlePush.js
new file mode 100644
index 0000000..8a9e64f
--- /dev/null
+++ b/src/utils/articlePush.js
@@ -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}
+ */
+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;
+}