From 95177c8df717e6a240deafef547ebe266aaf9774 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Sun, 2 Nov 2025 09:54:54 +0100 Subject: [PATCH] feat(carousel): add ProductCarousel component and integrate into SharedCarousel - Introduce a new ProductCarousel component for displaying products in a scrollable format. - Implement auto-scrolling functionality and manual navigation controls. - Integrate ProductCarousel into SharedCarousel for the 'neu' category, enhancing the user interface and product visibility. - Update Product component rendering within the carousel to ensure proper display of product details. --- src/components/Product.js | 8 +- src/components/ProductCarousel.js | 429 ++++++++++++++++++++++++++++++ src/components/SharedCarousel.js | 4 + 3 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 src/components/ProductCarousel.js diff --git a/src/components/Product.js b/src/components/Product.js index 081d68a..9eee0d1 100644 --- a/src/components/Product.js +++ b/src/components/Product.js @@ -393,9 +393,11 @@ class Product extends Component { ({this.props.t ? this.props.t('product.inclVatFooter', { vat }) : `incl. ${vat}% USt.,*`}) - {cGrundEinheit && fGrundPreis && fGrundPreis != price && ( - ({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(fGrundPreis)}/{cGrundEinheit}) - )} +
+ {cGrundEinheit && fGrundPreis && fGrundPreis != price && ( + ({new Intl.NumberFormat('de-DE', {style: 'currency', currency: currency || 'EUR'}).format(fGrundPreis)}/{cGrundEinheit}) + )} +
{/*incoming*/} diff --git a/src/components/ProductCarousel.js b/src/components/ProductCarousel.js new file mode 100644 index 0000000..edd8745 --- /dev/null +++ b/src/components/ProductCarousel.js @@ -0,0 +1,429 @@ +import React from 'react'; +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 + +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; + + console.log("ProductCarousel componentDidMount: Loading products for categoryId", this.props.categoryId, "language", currentLanguage); + this.loadProducts(currentLanguage); + } + + componentDidUpdate(prevProps) { + console.log("ProductCarousel componentDidUpdate", prevProps.languageContext?.currentLanguage, this.props.languageContext?.currentLanguage); + if(prevProps.languageContext?.currentLanguage !== this.props.languageContext?.currentLanguage) { + this.setState({ products: [] }, () => { + this.loadProducts(this.props.languageContext?.currentLanguage || this.props.i18n.language); + }); + } + } + + loadProducts = (language) => { + const { categoryId } = this.props; + + window.socketManager.emit( + "getCategoryProducts", + { + categoryId: categoryId === "neu" ? "neu" : categoryId, + language: language, + requestTranslation: language === 'de' ? false : true + }, + (response) => { + console.log("ProductCarousel getCategoryProducts response:", response); + if (this._isMounted && response && response.products && response.products.length > 0) { + // Filter products to only show those with pictures + const productsWithPictures = response.products.filter(product => + product.pictureList && product.pictureList.length > 0 + ); + console.log("ProductCarousel: Filtered", productsWithPictures.length, "products with pictures from", response.products.length, "total"); + + if (productsWithPictures.length > 0) { + this.originalProducts = productsWithPictures; + // Duplicate for seamless looping + this.products = [...productsWithPictures, ...productsWithPictures]; + this.setState({ products: this.products }); + this.startAutoScroll(); + } + } + } + ); + } + + 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()} +
+
+
+ ); + } +} + +export default withTranslation()(withLanguage(ProductCarousel)); + diff --git a/src/components/SharedCarousel.js b/src/components/SharedCarousel.js index 8e95112..bd79be9 100644 --- a/src/components/SharedCarousel.js +++ b/src/components/SharedCarousel.js @@ -5,6 +5,7 @@ import IconButton from "@mui/material/IconButton"; import ChevronLeft from "@mui/icons-material/ChevronLeft"; import ChevronRight from "@mui/icons-material/ChevronRight"; import CategoryBox from "./CategoryBox.js"; +import ProductCarousel from "./ProductCarousel.js"; import { withTranslation } from 'react-i18next'; import { withLanguage } from '../i18n/withTranslation.js'; @@ -398,6 +399,9 @@ class SharedCarousel extends React.Component { {this.renderVirtualScrollbar()}
+ + {/* Product Carousel for "neu" category */} + ); }