Compare commits

..

4 Commits

7 changed files with 194 additions and 14 deletions

View File

@@ -33,6 +33,7 @@ import i18n from './i18n/index.js';
import Header from "./components/Header.js";
import Footer from "./components/Footer.js";
import MainPageLayout from "./components/MainPageLayout.js";
import IdleMainPagesSlideshow from "./components/IdleMainPagesSlideshow.js";
import Content from "./components/Content.js";
import ProductDetail from "./components/ProductDetail.js";
@@ -253,6 +254,7 @@ const AppContent = ({ currentTheme, dynamicTheme, onThemeChange }) => {
)
}>
<CarouselProvider>
<IdleMainPagesSlideshow />
<Routes>
{/* Main pages using unified component */}
<Route path="/" element={<MainPageLayout />} />

View File

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

View File

@@ -0,0 +1,121 @@
import { useEffect, useRef, useCallback } from "react";
import { useLocation, useNavigate } from "react-router-dom";
/** Same order as the main landing tiles (home → Aktionen → Filiale). */
const MAIN_PAGE_PATHS = ["/", "/aktionen", "/filiale"];
/** No input for this long before the slideshow starts. */
const IDLE_MS = 90_000;
/** Time between automatic page changes once the slideshow is running. */
const SLIDESHOW_STEP_MS = 14_000;
/** Ignore duplicate events (mousemove etc.) within this window. */
const ACTIVITY_THROTTLE_MS = 400;
/**
* After auto-navigation, ignore user-activity handlers briefly — route changes
* often emit scroll / mousemove / focus events that would call resetIdle() and
* clear the slideshow interval (only one slide before stopping).
*/
const POST_NAV_GRACE_MS = 3_000;
/**
* After idle on /, /aktionen, or /filiale, cycles those routes slowly.
* Lives outside MainPageLayout so it is not reset when the route changes.
*/
export default function IdleMainPagesSlideshow() {
const location = useLocation();
const navigate = useNavigate();
const idleTimerRef = useRef(null);
const slideTimerRef = useRef(null);
const pathRef = useRef(location.pathname);
const wasOnMainPageRef = useRef(false);
const lastActivityRef = useRef(0);
const ignoreActivityUntilRef = useRef(0);
const resetIdleRef = useRef(() => {});
const clearTimersRef = useRef(() => {});
pathRef.current = location.pathname;
const clearTimers = useCallback(() => {
if (idleTimerRef.current != null) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
if (slideTimerRef.current != null) {
clearInterval(slideTimerRef.current);
slideTimerRef.current = null;
}
}, []);
clearTimersRef.current = clearTimers;
const startSlideshow = useCallback(() => {
let idx = MAIN_PAGE_PATHS.indexOf(pathRef.current);
if (idx < 0) idx = 0;
const advance = () => {
idx = (idx + 1) % MAIN_PAGE_PATHS.length;
ignoreActivityUntilRef.current = Date.now() + POST_NAV_GRACE_MS;
navigate(MAIN_PAGE_PATHS[idx], { replace: true });
};
slideTimerRef.current = setInterval(advance, SLIDESHOW_STEP_MS);
}, [navigate]);
const resetIdle = useCallback(() => {
clearTimers();
if (!MAIN_PAGE_PATHS.includes(pathRef.current)) return;
idleTimerRef.current = setTimeout(() => {
idleTimerRef.current = null;
startSlideshow();
}, IDLE_MS);
}, [clearTimers, startSlideshow]);
resetIdleRef.current = resetIdle;
useEffect(() => {
const nowMain = MAIN_PAGE_PATHS.includes(location.pathname);
if (!nowMain) {
clearTimers();
wasOnMainPageRef.current = false;
return;
}
if (!wasOnMainPageRef.current) {
resetIdle();
}
wasOnMainPageRef.current = true;
}, [location.pathname, clearTimers, resetIdle]);
useEffect(() => {
const onActivity = () => {
const now = Date.now();
if (now < ignoreActivityUntilRef.current) return;
if (now - lastActivityRef.current < ACTIVITY_THROTTLE_MS) return;
lastActivityRef.current = now;
resetIdleRef.current();
};
const events = [
"mousedown",
"keydown",
"touchstart",
"touchmove",
"wheel",
"click",
"scroll",
];
events.forEach((ev) =>
window.addEventListener(ev, onActivity, { passive: true })
);
window.addEventListener("mousemove", onActivity, { passive: true });
return () => {
events.forEach((ev) => window.removeEventListener(ev, onActivity));
window.removeEventListener("mousemove", onActivity);
clearTimersRef.current();
};
}, []);
return null;
}

View File

@@ -320,8 +320,8 @@ const MainPageLayout = () => {
{ title: t('sections.konfigurator'), image: "/assets/images/konfigurator.avif", bgcolor: "#e8f5d6", link: "/Konfigurator" }
],
aktionen: [
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/presseverleih" },
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/thc-test" }
{ title: t('sections.oilPress'), image: "/assets/images/presse.jpg", bgcolor: "#e1f0d3", link: "/Artikel/Graveda-10t-presse-tagesmiete-inkl-prepress-vorpressform" },
{ title: t('sections.thcTest'), image: "/assets/images/purpl.jpg", bgcolor: "#e8f5d6", link: "/Artikel/1x-messung-purplpro-thc-cbd-restfeuchte-wasseraktivitaet" }
],
filiale: [
{ title: t('sections.address1'), image: "/assets/images/filiale1.jpg", bgcolor: "#e1f0d3", link: "/filiale" },

View File

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

View File

@@ -2,6 +2,15 @@
* 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_VAPID_URL = "/api/check/push/vapid-public-key";
@@ -88,14 +97,22 @@ export async function articlePushSubscribe(kArtikel, subscription) {
return { ...data, httpOk: res.ok };
}
/**
* @param {string} endpoint
*/
export async function articlePushUnsubscribe(endpoint) {
/** POST /api/article/push/unsubscribe — body is always `{ endpoint, kArtikel }` (scoped row only). */
export async function articlePushUnsubscribe(endpoint, kArtikel) {
const parsed =
kArtikel != null && kArtikel !== ""
? typeof kArtikel === "number"
? kArtikel
: parseInt(String(kArtikel), 10)
: NaN;
if (!Number.isFinite(parsed) || parsed <= 0) {
return { success: false, httpOk: false, error: "missing_kArtikel" };
}
const body = { endpoint, kArtikel: parsed };
const res = await fetch("/api/article/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return { ...data, httpOk: res.ok };

View File

@@ -3,6 +3,8 @@
*/
export {
PUSH_SUBSCRIPTIONS_CHANGED_EVENT,
emitPushSubscriptionsChanged,
fetchPushConfiguration,
registerPushServiceWorker,
ensurePushSubscription,
@@ -43,14 +45,22 @@ export async function categoryPushSubscribe(kKategorie, subscription) {
return { ...data, httpOk: res.ok };
}
/**
* @param {string} endpoint
*/
export async function categoryPushUnsubscribe(endpoint) {
/** POST /api/category/push/unsubscribe — body is always `{ endpoint, kKategorie }` (scoped row only). */
export async function categoryPushUnsubscribe(endpoint, kKategorie) {
const parsed =
kKategorie != null && kKategorie !== ""
? typeof kKategorie === "number"
? kKategorie
: parseInt(String(kKategorie), 10)
: NaN;
if (!Number.isFinite(parsed) || parsed <= 0) {
return { success: false, httpOk: false, error: "missing_kKategorie" };
}
const body = { endpoint, kKategorie: parsed };
const res = await fetch("/api/category/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return { ...data, httpOk: res.ok };