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'; const ITEM_WIDTH = 250 + 16; // 250px width + 16px gap 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, }; this.carouselTrackRef = React.createRef(); } componentDidMount() { this._isMounted = true; 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; 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 originalItemCount = this.originalProducts.length; const maxScroll = ITEM_WIDTH * 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 originalItemCount = this.originalProducts.length; const maxScroll = ITEM_WIDTH * originalItemCount; this.translateX += direction * ITEM_WIDTH; // Handle wrap-around when scrolling left (positive translateX) if (this.translateX > 0) { this.translateX = -(maxScroll - ITEM_WIDTH); } // 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 originalItemCount = this.originalProducts.length; const viewportWidth = 1080; // carousel container max-width const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH); // 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 = ITEM_WIDTH * originalItemCount; const effectivePosition = maxScroll + this.translateX; currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH); } else { currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH); } // 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 (
); }; render() { const { t, title } = this.props; const { products } = this.state; if (!products || products.length === 0) { return null; } return ( {title || t('product.new')}
{/* Left Arrow */} {/* Right Arrow */}
{products.map((product, index) => (
))}
{/* Virtual Scrollbar */} {this.renderVirtualScrollbar()}
); } // Shuffle array using Fisher-Yates algorithm shuffleArray(array) { const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } } export default withTranslation()(withLanguage(ProductCarousel));