import React from 'react'; import { Link } from "react-router-dom"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import ChevronLeft from "@mui/icons-material/ChevronLeft"; import ChevronRight from "@mui/icons-material/ChevronRight"; import Product from "./Product.js"; import { withTranslation } from 'react-i18next'; import { withLanguage } from '../i18n/withTranslation.js'; import { getProductCarouselItemStridePx, PRODUCT_CARD_WIDTH_SM_PX, PRODUCT_CARD_WIDTH_XS_PX, } from "../utils/productCardLayout.js"; const AUTO_SCROLL_SPEED = 0.5; // px per frame (~60fps, so ~30px/sec) const AUTOSCROLL_RESTART_DELAY = 5000; // 5 seconds of inactivity before restarting autoscroll const SCROLLBAR_FLASH_DURATION = 3000; // 3 seconds to show the virtual scrollbar const CAROUSEL_LOG = "[ProductCarousel]"; function logCarousel(phase, detail) { try { console.log(CAROUSEL_LOG, phase, detail != null ? detail : ""); } catch { /* ignore */ } } /** Debug summary of getCategoryProducts callback / socket payload. */ function summarizeResponse(response, maxProducts = 8) { if (response == null) { return { type: "null" }; } if (typeof response !== "object") { return { type: typeof response, value: response }; } const keys = Object.keys(response); const products = response.products; const n = Array.isArray(products) ? products.length : null; const sample = Array.isArray(products) && products.length > 0 ? products.slice(0, maxProducts).map((p) => ({ id: p?.id, seoName: p?.seoName, available: p?.available, availableSupplier: p?.availableSupplier, hasPictureList: Boolean( p?.pictureList && (typeof p.pictureList === "string" ? p.pictureList.trim().length > 0 : Array.isArray(p.pictureList) ? p.pictureList.length > 0 : false) ), })) : []; return { keys, productsLength: n, sample }; } function productHasImage(p) { const pl = p?.pictureList; if (!pl) return false; if (typeof pl === "string") return pl.trim().length > 0; if (Array.isArray(pl)) return pl.length > 0; return false; } class ProductCarousel extends React.Component { _isMounted = false; products = []; originalProducts = []; animationFrame = null; autoScrollActive = true; translateX = 0; inactivityTimer = null; scrollbarTimer = null; constructor(props) { super(props); const { i18n } = props; this.state = { products: [], currentLanguage: (i18n && i18n.language) || 'de', showScrollbar: false, itemStride: typeof window !== "undefined" ? getProductCarouselItemStridePx() : PRODUCT_CARD_WIDTH_SM_PX + 16, }; this.carouselTrackRef = React.createRef(); } handleCarouselResize = () => { if (!this._isMounted) return; const next = getProductCarouselItemStridePx(); if (next !== this.state.itemStride) { this.translateX = 0; this.updateTrackTransform(); this.setState({ itemStride: next }); } }; componentDidMount() { this._isMounted = true; if (typeof window !== "undefined") { window.addEventListener("resize", this.handleCarouselResize); this.setState({ itemStride: getProductCarouselItemStridePx() }); } const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n.language; logCarousel("mount", { categoryId: this.props.categoryId, language: currentLanguage, filter_availability: typeof sessionStorage !== "undefined" ? sessionStorage.getItem("filter_availability") : "(no sessionStorage)", }); this.loadProducts(currentLanguage); } categoryIdsEqual(a, b) { if (Array.isArray(a) && Array.isArray(b)) { return a.length === b.length && a.every((v, i) => v === b[i]); } return a === b; } componentDidUpdate(prevProps) { const lang = this.props.languageContext?.currentLanguage || this.props.i18n.language; const langChanged = prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage; const catChanged = !this.categoryIdsEqual( prevProps.categoryId, this.props.categoryId ); if (langChanged || catChanged) { logCarousel("didUpdate:reload", { langChanged, catChanged, prevCategoryId: prevProps.categoryId, categoryId: this.props.categoryId, language: lang, }); } if (langChanged) { this.setState({ products: [] }, () => { this.loadProducts(lang); }); return; } if (catChanged) { this.setState({ products: [] }, () => { this.loadProducts(lang); }); } } /** Virtual slugs go to the socket as strings; other ids pass through. */ normalizeSocketCategoryId(id) { if (id === "neu" || id === "bald") return id; return id; } /** * neu: stub only (no `full`). bald: `full: true` — one emit ack with the full list. * Carousel does not subscribe to `productList:*` (no second update). */ buildGetCategoryProductsPayload(id, language) { const slug = this.normalizeSocketCategoryId(id); const payload = { categoryId: slug, language, requestTranslation: language === "de" ? false : true, }; if (slug === "bald") { payload.full = true; } return payload; } /** Single getCategoryProducts response (emit ack only). */ emitGetCategoryProductsOnce = (rawId, language, onAck) => { const slug = this.normalizeSocketCategoryId(rawId); const payload = this.buildGetCategoryProductsPayload(rawId, language); logCarousel("emit getCategoryProducts (ack only)", { slug, payload }); window.socketManager.emit("getCategoryProducts", payload, (response) => { logCarousel("getCategoryProducts CALLBACK", { slug, summary: summarizeResponse(response), }); onAck(response); }); }; finalizeCarouselList = (products) => { if (!this._isMounted) { logCarousel("finalizeCarouselList:skip (unmounted)", { incomingLen: products?.length, }); return; } if (!products || products.length === 0) { logCarousel("finalizeCarouselList:skip (empty)", {}); return; } const shuffledProducts = this.shuffleArray(products).slice(0, 15); logCarousel("finalizeCarouselList:ok", { inputCount: products.length, afterShuffleCap15: shuffledProducts.length, ids: shuffledProducts.map((p) => p?.id), }); this.originalProducts = shuffledProducts; this.products = [...shuffledProducts, ...shuffledProducts]; this.setState({ products: this.products }); this.startAutoScroll(); }; processLoadedProducts = (rawProducts, sourceTag = "processLoadedProducts") => { if (!this._isMounted) { logCarousel(`${sourceTag}:skip (unmounted)`, { rawLen: rawProducts?.length, }); return; } if (!rawProducts || rawProducts.length === 0) { logCarousel(`${sourceTag}:skip (empty products)`, {}); return; } logCarousel(`${sourceTag}:incoming`, { count: rawProducts.length, idsSample: rawProducts.slice(0, 12).map((p) => p?.id), }); this.finalizeCarouselList(rawProducts.slice()); }; /** Merge neu + bald; neu ids win on duplicate. */ processNeuAndBaldMerged = (neuRaw, baldRaw) => { const neuList = neuRaw || []; const baldList = baldRaw || []; const seen = new Set(); const merged = []; for (const p of neuList) { if (p && p.id != null && !seen.has(p.id)) { seen.add(p.id); merged.push(p); } } let baldWithImage = 0; for (const p of baldList) { if (!p || p.id == null || seen.has(p.id)) continue; if (!productHasImage(p)) continue; baldWithImage += 1; seen.add(p.id); merged.push(p); } logCarousel("processNeuAndBaldMerged:counts", { neu: neuList.length, bald: baldList.length, baldWithImage, mergedUnique: merged.length, neuIdsSample: neuList.slice(0, 8).map((p) => p?.id), baldIdsSample: baldList.slice(0, 8).map((p) => p?.id), }); if (merged.length === 0) { logCarousel("processNeuAndBaldMerged:skip (merged empty)", {}); return; } this.finalizeCarouselList(merged); }; loadProducts = (language) => { const { categoryId } = this.props; const ids = Array.isArray(categoryId) ? categoryId : [categoryId]; const filterAvailability = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("filter_availability") : null; logCarousel("loadProducts:start", { language, categoryId, ids, filter_availability: filterAvailability, }); if (ids.length === 1) { const slug = this.normalizeSocketCategoryId(ids[0]); this.emitGetCategoryProductsOnce(ids[0], language, (response) => { let products = response?.products ?? []; if (slug === "bald") { const before = products.length; products = products.filter(productHasImage); logCarousel("loadProducts:single:bald filter image", { before, after: products.length, }); } if (products.length > 0) { this.processLoadedProducts(products, "single"); } else { logCarousel("loadProducts:single: empty", { slug }); } }); return; } const neuBaldMode = ids.length === 2 && ids.map((x) => this.normalizeSocketCategoryId(x)).sort().join(",") === "bald,neu"; if (neuBaldMode) { logCarousel("loadProducts:mode neu+bald (stub neu + full bald → merge → shuffle → max 15)", { ids, }); let neuProducts = null; let baldProducts = null; const tryMerge = () => { if (neuProducts === null || baldProducts === null) { return; } logCarousel("loadProducts:neu+bald merge inputs", { neuLen: neuProducts.length, baldLen: baldProducts.length, }); this.processNeuAndBaldMerged(neuProducts, baldProducts); }; this.emitGetCategoryProductsOnce("neu", language, (response) => { neuProducts = response?.products ?? []; tryMerge(); }); this.emitGetCategoryProductsOnce("bald", language, (response) => { baldProducts = response?.products ?? []; tryMerge(); }); return; } logCarousel("loadProducts:mode generic merge", { ids }); const mergedById = new Map(); let remaining = ids.length; const onResponse = (response, idForLog) => { logCarousel("loadProducts:merge CALLBACK", { id: idForLog, summary: summarizeResponse(response), }); let products = response?.products ?? []; if (this.normalizeSocketCategoryId(idForLog) === "bald") { products = products.filter(productHasImage); } if (products.length > 0) { for (const p of products) { if (p && p.id != null && !mergedById.has(p.id)) { mergedById.set(p.id, p); } } } remaining -= 1; if (remaining === 0) { logCarousel("loadProducts:merge complete", { unique: mergedById.size, idsSample: Array.from(mergedById.keys()).slice(0, 15), }); this.processLoadedProducts( Array.from(mergedById.values()), "genericMerge" ); } }; ids.forEach((id) => { this.emitGetCategoryProductsOnce(id, language, (response) => { onResponse(response, id); }); }); } componentWillUnmount() { this._isMounted = false; if (typeof window !== "undefined") { window.removeEventListener("resize", this.handleCarouselResize); } this.stopAutoScroll(); this.clearInactivityTimer(); this.clearScrollbarTimer(); } startAutoScroll = () => { this.autoScrollActive = true; if (!this.animationFrame) { this.animationFrame = requestAnimationFrame(this.handleAutoScroll); } }; stopAutoScroll = () => { this.autoScrollActive = false; if (this.animationFrame) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } }; clearInactivityTimer = () => { if (this.inactivityTimer) { clearTimeout(this.inactivityTimer); this.inactivityTimer = null; } }; clearScrollbarTimer = () => { if (this.scrollbarTimer) { clearTimeout(this.scrollbarTimer); this.scrollbarTimer = null; } }; startInactivityTimer = () => { this.clearInactivityTimer(); this.inactivityTimer = setTimeout(() => { if (this._isMounted) { this.startAutoScroll(); } }, AUTOSCROLL_RESTART_DELAY); }; showScrollbarFlash = () => { this.clearScrollbarTimer(); this.setState({ showScrollbar: true }); this.scrollbarTimer = setTimeout(() => { if (this._isMounted) { this.setState({ showScrollbar: false }); } }, SCROLLBAR_FLASH_DURATION); }; handleAutoScroll = () => { if (!this.autoScrollActive || this.originalProducts.length === 0) return; this.translateX -= AUTO_SCROLL_SPEED; this.updateTrackTransform(); const { itemStride } = this.state; const originalItemCount = this.originalProducts.length; const maxScroll = itemStride * originalItemCount; // Check if we've scrolled past the first set of items if (Math.abs(this.translateX) >= maxScroll) { // Reset to beginning seamlessly this.translateX = 0; this.updateTrackTransform(); } this.animationFrame = requestAnimationFrame(this.handleAutoScroll); }; updateTrackTransform = () => { if (this.carouselTrackRef.current) { this.carouselTrackRef.current.style.transform = `translateX(${this.translateX}px)`; } }; handleLeftClick = () => { this.stopAutoScroll(); this.scrollBy(1); this.showScrollbarFlash(); this.startInactivityTimer(); }; handleRightClick = () => { this.stopAutoScroll(); this.scrollBy(-1); this.showScrollbarFlash(); this.startInactivityTimer(); }; scrollBy = (direction) => { if (this.originalProducts.length === 0) return; // direction: 1 = left (scroll content right), -1 = right (scroll content left) const { itemStride } = this.state; const originalItemCount = this.originalProducts.length; const maxScroll = itemStride * originalItemCount; this.translateX += direction * itemStride; // Handle wrap-around when scrolling left (positive translateX) if (this.translateX > 0) { this.translateX = -(maxScroll - itemStride); } // Handle wrap-around when scrolling right (negative translateX beyond limit) else if (Math.abs(this.translateX) >= maxScroll) { this.translateX = 0; } this.updateTrackTransform(); // Force scrollbar to update immediately after wrap-around if (this.state.showScrollbar) { this.forceUpdate(); } }; renderVirtualScrollbar = () => { if (!this.state.showScrollbar || this.originalProducts.length === 0) { return null; } const { itemStride } = this.state; const originalItemCount = this.originalProducts.length; const viewportWidth = typeof window !== "undefined" ? Math.min(1080, Math.max(0, window.innerWidth - 56)) : 1080; const itemsInView = Math.max(1, Math.floor(viewportWidth / itemStride)); // Calculate which item is currently at the left edge (first visible) let currentItemIndex; if (this.translateX === 0) { currentItemIndex = 0; } else if (this.translateX > 0) { const maxScroll = itemStride * originalItemCount; const effectivePosition = maxScroll + this.translateX; currentItemIndex = Math.floor(effectivePosition / itemStride); } else { currentItemIndex = Math.floor(Math.abs(this.translateX) / itemStride); } // Ensure we stay within bounds currentItemIndex = Math.max(0, Math.min(currentItemIndex, originalItemCount - 1)); // Calculate scrollbar position const lastPossibleFirstItem = Math.max(0, originalItemCount - itemsInView); const thumbPosition = lastPossibleFirstItem > 0 ? Math.min((currentItemIndex / lastPossibleFirstItem) * 100, 100) : 0; return (