feat: Enhance ChatAssistant component with dynamic privacy prompt and localization support; update various UI elements for improved accessibility and user experience

Fix product card width on mobile.
This commit is contained in:
sebseb7
2026-03-26 14:21:03 +01:00
parent bfeb5be1d5
commit e0c6d47d98
31 changed files with 485 additions and 91 deletions

View File

@@ -52,7 +52,12 @@ class ChatAssistant extends Component {
this.fileInputRef = React.createRef();
this.recordingTimer = null;
}
buildPrivacyPromptHtml = () => {
const { t } = this.props;
return `${t('chat.privacyPromptBefore')}<a href="/datenschutz" target="_blank" rel="noopener noreferrer">${t('chat.privacyPolicyLink')}</a>${t('chat.privacyPromptAfter')}<button data-confirm-privacy="true">${t('chat.privacyRead')}</button>`;
};
componentDidMount() {
// Add socket listeners if socket is available and connected
this.addSocketListeners();
@@ -69,7 +74,7 @@ class ChatAssistant extends Component {
const privacyMessage = {
id: 'privacy-prompt',
sender: 'bot',
text: 'Bitte bestätigen Sie, dass Sie die <a href="/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> gelesen haben und damit einverstanden sind. <button data-confirm-privacy="true">Gelesen & Akzeptiert</button>',
text: this.buildPrivacyPromptHtml(),
};
const updatedMessages = [privacyMessage, ...prevState.messages];
window.chatMessages = updatedMessages;
@@ -84,6 +89,19 @@ class ChatAssistant extends Component {
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.i18n?.language !== this.props.i18n?.language) {
this.setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === 'privacy-prompt');
if (idx === -1) return null;
const updatedMessages = [...prev.messages];
updatedMessages[idx] = {
...updatedMessages[idx],
text: this.buildPrivacyPromptHtml(),
};
window.chatMessages = updatedMessages;
return { messages: updatedMessages };
});
}
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
this.scrollToBottom();
}
@@ -244,7 +262,7 @@ class ChatAssistant extends Component {
});
} catch (err) {
console.error("Error accessing microphone:", err);
alert("Could not access microphone. Please check your browser permissions.");
alert(this.props.t('chat.micPermissionDenied'));
}
};
@@ -359,7 +377,7 @@ class ChatAssistant extends Component {
const newUserMessage = {
id: Date.now(),
sender: 'user',
text: `<img src="${imageUrl}" alt="Uploaded image" style="max-width: 100%; height: auto; border-radius: 8px;" />`,
text: `<img src="${imageUrl}" alt="${this.props.t('chat.uploadedImageAlt')}" style="max-width: 100%; height: auto; border-radius: 8px;" />`,
isImage: true
};
@@ -451,7 +469,7 @@ class ChatAssistant extends Component {
}
if (domNode.name === 'button' && domNode.attribs && domNode.attribs['data-confirm-privacy']) {
return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>Gelesen & Akzeptiert</Button>;
return <Button variant="contained" size="small" onClick={this.handlePrivacyConfirm}>{this.props.t('chat.privacyRead')}</Button>;
}
}
});
@@ -505,12 +523,12 @@ class ChatAssistant extends Component {
}}
>
<Typography variant="h6" component="div">
Assistent
{t('chat.assistantTitle')}
<Typography component="span" color={this.state.aiThink ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🧠</Typography>
<Typography component="span" color={this.state.atDatabase ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🛢</Typography>
<Typography component="span" color={this.state.atWeb ? "error" : "text.disabled"} sx={{ display: 'inline' }}>🌐</Typography>
</Typography>
<IconButton onClick={onClose} size="small" aria-label="Assistent schließen" sx={{ color: 'primary.contrastText' }}>
<IconButton onClick={onClose} size="small" aria-label={t('chat.closeAria')} sx={{ color: 'primary.contrastText' }}>
<CloseIcon />
</IconButton>
</Box>
@@ -648,7 +666,7 @@ class ChatAssistant extends Component {
autoFocus
autoCapitalize="off"
autoCorrect="off"
placeholder={isRecording ? "Aufnahme läuft..." : "Du kannst mich nach Cannabissorten fragen..."}
placeholder={isRecording ? t('chat.placeholderRecording') : t('chat.inputPlaceholder')}
value={inputValue}
onChange={this.handleInputChange}
onKeyDown={this.handleKeyDown}
@@ -670,7 +688,7 @@ class ChatAssistant extends Component {
<IconButton
color="error"
onClick={this.stopRecording}
aria-label="Aufnahme stoppen"
aria-label={t('chat.micStopAria')}
sx={{ ml: { xs: 0, sm: 1 } }}
>
<StopIcon />
@@ -679,7 +697,7 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.startRecording}
aria-label="Sprachaufnahme starten"
aria-label={t('chat.micStartAria')}
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || inputsDisabled}
>
@@ -690,7 +708,7 @@ class ChatAssistant extends Component {
<IconButton
color="primary"
onClick={this.handleImageUpload}
aria-label="Bild hochladen"
aria-label={t('chat.uploadImageAria')}
sx={{ ml: { xs: 0, sm: 1 } }}
disabled={isTyping || isRecording || inputsDisabled}
>
@@ -703,7 +721,7 @@ class ChatAssistant extends Component {
onClick={this.handleSendMessage}
disabled={isTyping || isRecording || inputsDisabled}
>
Senden
{t('chat.send')}
</Button>
</Box>
</Box>

View File

@@ -11,6 +11,11 @@ import { Link, useNavigate } from 'react-router-dom';
import { withI18n } from '../i18n/withTranslation.js';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import { STAR_POLYGON_POINTS } from '../utils/starPolygon.js';
import {
PRODUCT_CARD_MOBILE_MAX_WIDTH_PX,
PRODUCT_CARD_WIDTH_SM_PX,
PRODUCT_CARD_WIDTH_XS_PX,
} from '../utils/productCardLayout.js';
// Helper function to find level 1 category ID from any category ID
const findLevel1CategoryId = (categoryId) => {
@@ -276,7 +281,16 @@ class Product extends Component {
<Box sx={{
position: 'relative',
height: '100%',
width: { xs: '100%', sm: 'auto' }
/* Match card width on xs so absolute NEU star is relative to the card, not the full grid row */
width: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: 'auto',
},
minWidth: { xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`, sm: 'auto' },
maxWidth: { xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`, sm: 'none' },
display: 'flex',
justifyContent: { xs: 'center', sm: 'flex-start' },
mx: { xs: 'auto', sm: 0 },
}}>
{isNew && (
<div
@@ -362,22 +376,36 @@ class Product extends Component {
<Card
sx={{
width: { xs: '100vw', sm: '250px' },
minWidth: { xs: '100vw', sm: '250px' },
width: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
minWidth: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
maxWidth: {
xs: `${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
position: 'relative',
overflow: 'hidden',
borderRadius: { xs: 0, sm: '8px' },
border: { xs: 'none', sm: 'inherit' },
boxShadow: { xs: 'none', sm: 'inherit' },
mx: { xs: 0, sm: 'auto' },
borderRadius: { xs: '8px', sm: '8px' },
border: { xs: '1px solid', sm: 'inherit' },
borderColor: { xs: 'divider', sm: 'inherit' },
boxShadow: { xs: '0 1px 4px rgba(0,0,0,0.08)', sm: 'inherit' },
mx: { xs: 'auto', sm: 'auto' },
'&:hover': {
transform: { xs: 'none', sm: 'translateY(-5px)' },
boxShadow: { xs: 'none', sm: '0px 10px 20px rgba(0,0,0,0.1)' }
}
boxShadow: {
xs: '0 1px 4px rgba(0,0,0,0.08)',
sm: '0px 10px 20px rgba(0,0,0,0.1)',
},
},
}}
>
{showThcBadge && (
@@ -460,7 +488,7 @@ class Product extends Component {
<CardMedia
key={index}
component="img"
height={window.innerWidth < 600 ? "240" : "180"}
height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
image={imgSrc}
alt={name}
fetchPriority={this.props.priority === 'high' && index === 0 ? 'high' : 'auto'}
@@ -489,7 +517,7 @@ class Product extends Component {
) : (
<CardMedia
component="img"
height={window.innerWidth < 600 ? "240" : "180"}
height={window.innerWidth < PRODUCT_CARD_MOBILE_MAX_WIDTH_PX ? "240" : "180"}
image="/assets/images/nopicture.jpg"
alt={name}
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}

View File

@@ -8,8 +8,11 @@ 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
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
@@ -81,13 +84,31 @@ class ProductCarousel extends React.Component {
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", {
@@ -370,6 +391,9 @@ class ProductCarousel extends React.Component {
componentWillUnmount() {
this._isMounted = false;
if (typeof window !== "undefined") {
window.removeEventListener("resize", this.handleCarouselResize);
}
this.stopAutoScroll();
this.clearInactivityTimer();
this.clearScrollbarTimer();
@@ -430,8 +454,9 @@ class ProductCarousel extends React.Component {
this.translateX -= AUTO_SCROLL_SPEED;
this.updateTrackTransform();
const { itemStride } = this.state;
const originalItemCount = this.originalProducts.length;
const maxScroll = ITEM_WIDTH * originalItemCount;
const maxScroll = itemStride * originalItemCount;
// Check if we've scrolled past the first set of items
if (Math.abs(this.translateX) >= maxScroll) {
@@ -467,14 +492,15 @@ class ProductCarousel extends React.Component {
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 = ITEM_WIDTH * originalItemCount;
const maxScroll = itemStride * originalItemCount;
this.translateX += direction * ITEM_WIDTH;
this.translateX += direction * itemStride;
// Handle wrap-around when scrolling left (positive translateX)
if (this.translateX > 0) {
this.translateX = -(maxScroll - ITEM_WIDTH);
this.translateX = -(maxScroll - itemStride);
}
// Handle wrap-around when scrolling right (negative translateX beyond limit)
else if (Math.abs(this.translateX) >= maxScroll) {
@@ -494,9 +520,13 @@ class ProductCarousel extends React.Component {
return null;
}
const { itemStride } = this.state;
const originalItemCount = this.originalProducts.length;
const viewportWidth = 1080; // carousel container max-width
const itemsInView = Math.floor(viewportWidth / ITEM_WIDTH);
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;
@@ -504,11 +534,11 @@ class ProductCarousel extends React.Component {
if (this.translateX === 0) {
currentItemIndex = 0;
} else if (this.translateX > 0) {
const maxScroll = ITEM_WIDTH * originalItemCount;
const maxScroll = itemStride * originalItemCount;
const effectivePosition = maxScroll + this.translateX;
currentItemIndex = Math.floor(effectivePosition / ITEM_WIDTH);
currentItemIndex = Math.floor(effectivePosition / itemStride);
} else {
currentItemIndex = Math.floor(Math.abs(this.translateX) / ITEM_WIDTH);
currentItemIndex = Math.floor(Math.abs(this.translateX) / itemStride);
}
// Ensure we stay within bounds
@@ -615,7 +645,7 @@ class ProductCarousel extends React.Component {
top: '50%',
left: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
@@ -635,7 +665,7 @@ class ProductCarousel extends React.Component {
top: '50%',
right: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
@@ -676,16 +706,19 @@ class ProductCarousel extends React.Component {
}}
>
{products.map((product, index) => (
<div
<Box
key={`${product.id}-${index}`}
className="product-carousel-item"
style={{
flex: '0 0 250px',
width: '250px',
maxWidth: '250px',
minWidth: '250px',
boxSizing: 'border-box',
position: 'relative'
sx={{
flex: {
xs: `0 0 ${PRODUCT_CARD_WIDTH_XS_PX}px`,
sm: `0 0 ${PRODUCT_CARD_WIDTH_SM_PX}px`,
},
width: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
maxWidth: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
minWidth: { xs: PRODUCT_CARD_WIDTH_XS_PX, sm: PRODUCT_CARD_WIDTH_SM_PX },
boxSizing: "border-box",
position: "relative",
}}
>
<Product
@@ -713,7 +746,7 @@ class ProductCarousel extends React.Component {
priority={index < 6 ? 'high' : 'auto'}
t={t}
/>
</div>
</Box>
))}
</div>

View File

@@ -438,7 +438,11 @@ class ProductList extends Component {
</Stack>
</Box>
<Grid container spacing={{ xs: 0, sm: 2 }}>
<Grid
container
spacing={{ xs: 0, sm: 2 }}
sx={{ bgcolor: { xs: '#e8f5e8', sm: 'transparent' } }}
>
{this.renderNoProductsMessage()}
{products.map((product, index) => (
<Grid
@@ -448,6 +452,7 @@ class ProductList extends Component {
justifyContent: { xs: 'stretch', sm: 'center' },
mb: { xs: 0, sm: 1 },
width: { xs: '100%', sm: 'auto' },
bgcolor: { xs: '#e8f5e8', sm: 'transparent' },
borderBottom: {
xs: index < products.length - 1 ? '16px solid #e8f5e8' : 'none',
sm: 'none'

View File

@@ -327,7 +327,7 @@ class SharedCarousel extends React.Component {
top: '50%',
left: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',
@@ -347,7 +347,7 @@ class SharedCarousel extends React.Component {
top: '50%',
right: '8px',
transform: 'translateY(-50%)',
zIndex: 1200,
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
width: '48px',

View File

@@ -519,7 +519,7 @@ class CategoryList extends Component {
to="/Konfigurator"
color="inherit"
size="small"
aria-label="Zur Startseite"
aria-label={this.props.t ? this.props.t('navigation.konfiguratorAria') : 'Zum Konfigurator'}
onClick={isMobile ? this.handleMobileCategoryClick : undefined}
sx={{
fontSize: "0.75rem",
@@ -572,7 +572,7 @@ class CategoryList extends Component {
zIndex: 2,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
{this.props.t ? this.props.t('sections.konfigurator') : 'Konfigurator'}
</Box>
{/* Thin text (positioned on top) */}
<Box
@@ -586,7 +586,7 @@ class CategoryList extends Component {
zIndex: 1,
}}
>
{this.props.t ? this.props.t('navigation.home') : 'Startseite'}
{this.props.t ? this.props.t('sections.konfigurator') : 'Konfigurator'}
</Box>
</Box>
)}