feat: Implement push subscription event handling in AddToCartButton and ProductFilters components; enhance article and category unsubscribe functionality with optional identifiers

This commit is contained in:
sebseb7
2026-03-26 21:28:49 +01:00
parent ba66b82b2b
commit 188c883450
4 changed files with 75 additions and 6 deletions

View File

@@ -15,6 +15,8 @@ import NotificationsActiveIcon from "@mui/icons-material/NotificationsActive";
import CircularProgress from "@mui/material/CircularProgress"; import CircularProgress from "@mui/material/CircularProgress";
import { withI18n } from "../i18n/withTranslation.js"; import { withI18n } from "../i18n/withTranslation.js";
import { import {
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
emitPushSubscriptionsChanged,
isPushApiSupported, isPushApiSupported,
fetchPushConfiguration, fetchPushConfiguration,
registerPushServiceWorker, registerPushServiceWorker,
@@ -109,9 +111,10 @@ class AddToCartButton extends Component {
this.setState({ pushSubscribed: false, pushBusy: false }); this.setState({ pushSubscribed: false, pushBusy: false });
return; return;
} }
const res = await articlePushUnsubscribe(subscription.endpoint); const res = await articlePushUnsubscribe(subscription.endpoint, kArtikel);
if (parseSuccess(res)) { if (parseSuccess(res)) {
this.setState({ pushSubscribed: false }); this.setState({ pushSubscribed: false });
emitPushSubscriptionsChanged();
} else { } else {
this.setState({ this.setState({
pushError: pushError:
@@ -146,6 +149,7 @@ class AddToCartButton extends Component {
const res = await articlePushSubscribe(kArtikel, subscription); const res = await articlePushSubscribe(kArtikel, subscription);
if (parseSuccess(res)) { if (parseSuccess(res)) {
this.setState({ pushSubscribed: true }); this.setState({ pushSubscribed: true });
emitPushSubscriptionsChanged();
} else { } else {
this.setState({ this.setState({
pushError: pushError:
@@ -174,7 +178,14 @@ class AddToCartButton extends Component {
if (this.state.quantity !== newQuantity) if (this.state.quantity !== newQuantity)
this.setState({ quantity: newQuantity }); this.setState({ quantity: newQuantity });
}; };
this.onPushSubscriptionsChanged = () => {
this.refreshIncomingPushStatus();
};
window.addEventListener("cart", this.cart); window.addEventListener("cart", this.cart);
window.addEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this.refreshIncomingPushStatus(); this.refreshIncomingPushStatus();
} }
@@ -190,6 +201,10 @@ class AddToCartButton extends Component {
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener("cart", this.cart); window.removeEventListener("cart", this.cart);
window.removeEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
} }
handleIncrement = () => { handleIncrement = () => {

View File

@@ -12,6 +12,8 @@ import { useParams, useSearchParams, useNavigate, useLocation } from 'react-rout
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js'; import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
import { withI18n } from '../i18n/withTranslation.js'; import { withI18n } from '../i18n/withTranslation.js';
import { import {
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
emitPushSubscriptionsChanged,
isPushApiSupported, isPushApiSupported,
fetchPushConfiguration, fetchPushConfiguration,
registerPushServiceWorker, registerPushServiceWorker,
@@ -68,14 +70,25 @@ class ProductFilters extends Component {
} }
componentDidMount() { componentDidMount() {
this.onPushSubscriptionsChanged = () => {
this.refreshCategoryPushStatus();
};
this.adjustPaperHeight(); this.adjustPaperHeight();
window.addEventListener('resize', this.adjustPaperHeight); window.addEventListener('resize', this.adjustPaperHeight);
window.addEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this._loadManufacturerImages(); this._loadManufacturerImages();
this.refreshCategoryPushStatus(); this.refreshCategoryPushStatus();
} }
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener('resize', this.adjustPaperHeight); window.removeEventListener('resize', this.adjustPaperHeight);
window.removeEventListener(
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
this.onPushSubscriptionsChanged
);
this._manufacturerImageUrls.forEach(url => URL.revokeObjectURL(url)); this._manufacturerImageUrls.forEach(url => URL.revokeObjectURL(url));
} }
@@ -204,9 +217,10 @@ class ProductFilters extends Component {
this.setState({ pushSubscribed: false, pushBusy: false }); this.setState({ pushSubscribed: false, pushBusy: false });
return; return;
} }
const res = await categoryPushUnsubscribe(subscription.endpoint); const res = await categoryPushUnsubscribe(subscription.endpoint, kKat);
if (parseSuccess(res)) { if (parseSuccess(res)) {
this.setState({ pushSubscribed: false }); this.setState({ pushSubscribed: false });
emitPushSubscriptionsChanged();
} else { } else {
this.setState({ this.setState({
pushError: pushError:
@@ -237,6 +251,7 @@ class ProductFilters extends Component {
const res = await categoryPushSubscribe(kKat, subscription); const res = await categoryPushSubscribe(kKat, subscription);
if (parseSuccess(res)) { if (parseSuccess(res)) {
this.setState({ pushSubscribed: true }); this.setState({ pushSubscribed: true });
emitPushSubscriptionsChanged();
} else { } else {
this.setState({ this.setState({
pushError: pushError:

View File

@@ -2,6 +2,15 @@
* Web Push helpers for article notifications (VAPID + backend API). * Web Push helpers for article notifications (VAPID + backend API).
*/ */
/** Fired after any article/category push subscribe or unsubscribe so all UIs re-query status. */
export const PUSH_SUBSCRIPTIONS_CHANGED_EVENT = "growheads-push-subscriptions-changed";
export function emitPushSubscriptionsChanged() {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(PUSH_SUBSCRIPTIONS_CHANGED_EVENT));
}
}
export const ARTICLE_PUSH_SW_PATH = "/api/check/push/service-worker"; export const ARTICLE_PUSH_SW_PATH = "/api/check/push/service-worker";
export const ARTICLE_PUSH_VAPID_URL = "/api/check/push/vapid-public-key"; export const ARTICLE_PUSH_VAPID_URL = "/api/check/push/vapid-public-key";
@@ -89,13 +98,28 @@ export async function articlePushSubscribe(kArtikel, subscription) {
} }
/** /**
* POST /api/article/push/unsubscribe — body: `{ endpoint }` or `{ subscription: { endpoint } }` (pickup-style).
* Optional `kArtikel` (number or numeric string): if it parses to a finite id > 0, only that row is removed.
* Otherwise legacy: server clears all push rows for that endpoint (article + category + pickup).
*
* @param {string} endpoint * @param {string} endpoint
* @param {number|string} [kArtikel]
*/ */
export async function articlePushUnsubscribe(endpoint) { export async function articlePushUnsubscribe(endpoint, kArtikel) {
const parsed =
kArtikel != null && kArtikel !== ""
? typeof kArtikel === "number"
? kArtikel
: parseInt(String(kArtikel), 10)
: NaN;
const body =
Number.isFinite(parsed) && parsed > 0
? { endpoint, kArtikel: parsed }
: { endpoint };
const res = await fetch("/api/article/push/unsubscribe", { const res = await fetch("/api/article/push/unsubscribe", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }), body: JSON.stringify(body),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
return { ...data, httpOk: res.ok }; return { ...data, httpOk: res.ok };

View File

@@ -3,6 +3,8 @@
*/ */
export { export {
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
emitPushSubscriptionsChanged,
fetchPushConfiguration, fetchPushConfiguration,
registerPushServiceWorker, registerPushServiceWorker,
ensurePushSubscription, ensurePushSubscription,
@@ -44,13 +46,26 @@ export async function categoryPushSubscribe(kKategorie, subscription) {
} }
/** /**
* POST /api/category/push/unsubscribe — same contract as article: optional `kKategorie` for scoped delete.
*
* @param {string} endpoint * @param {string} endpoint
* @param {number|string} [kKategorie]
*/ */
export async function categoryPushUnsubscribe(endpoint) { export async function categoryPushUnsubscribe(endpoint, kKategorie) {
const parsed =
kKategorie != null && kKategorie !== ""
? typeof kKategorie === "number"
? kKategorie
: parseInt(String(kKategorie), 10)
: NaN;
const body =
Number.isFinite(parsed) && parsed > 0
? { endpoint, kKategorie: parsed }
: { endpoint };
const res = await fetch("/api/category/push/unsubscribe", { const res = await fetch("/api/category/push/unsubscribe", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }), body: JSON.stringify(body),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
return { ...data, httpOk: res.ok }; return { ...data, httpOk: res.ok };