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 ( - + "& .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; +}