feat: Enhance ChatAssistant and ProductFilters components with dynamic privacy prompts and category push notification support; update localization strings for new article notifications across multiple languages
This commit is contained in:
@@ -58,17 +58,48 @@ class ChatAssistant extends Component {
|
||||
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>`;
|
||||
};
|
||||
|
||||
/** Keep stored privacy bubble in sync with i18n (language switcher, lazy bundle load). */
|
||||
applyPrivacyPromptTranslation = () => {
|
||||
this.setState((prev) => {
|
||||
if (prev.privacyConfirmed) return null;
|
||||
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 };
|
||||
});
|
||||
};
|
||||
|
||||
handleI18nLanguageChanged = () => {
|
||||
this.applyPrivacyPromptTranslation();
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// Add socket listeners if socket is available and connected
|
||||
this.addSocketListeners();
|
||||
|
||||
this.props.i18n?.on('languageChanged', this.handleI18nLanguageChanged);
|
||||
|
||||
const userStatus = isUserLoggedIn();
|
||||
const isGuest = !userStatus.isLoggedIn;
|
||||
|
||||
if (isGuest && !this.state.privacyConfirmed) {
|
||||
this.setState(prevState => {
|
||||
if (prevState.messages.find(msg => msg.id === 'privacy-prompt')) {
|
||||
return { isGuest: true };
|
||||
const updatedMessages = prevState.messages.map((msg) =>
|
||||
msg.id === 'privacy-prompt'
|
||||
? { ...msg, text: this.buildPrivacyPromptHtml() }
|
||||
: msg
|
||||
);
|
||||
window.chatMessages = updatedMessages;
|
||||
return {
|
||||
messages: updatedMessages,
|
||||
isGuest: true,
|
||||
};
|
||||
}
|
||||
|
||||
const privacyMessage = {
|
||||
@@ -90,17 +121,7 @@ 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 };
|
||||
});
|
||||
this.applyPrivacyPromptTranslation();
|
||||
}
|
||||
if (prevState.messages !== this.state.messages || prevState.isTyping !== this.state.isTyping) {
|
||||
this.scrollToBottom();
|
||||
@@ -108,6 +129,7 @@ class ChatAssistant extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.i18n?.off('languageChanged', this.handleI18nLanguageChanged);
|
||||
this.removeSocketListeners();
|
||||
this.stopRecording();
|
||||
if (this.recordingTimer) {
|
||||
|
||||
@@ -448,6 +448,29 @@ class Content extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// JTL kKategorie for category push: backend may omit dataParam — resolve from tree (same id as product list)
|
||||
const isValidJtlCategoryId = (v) => {
|
||||
if (v == null || v === '') return false;
|
||||
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||||
return Number.isFinite(n) && n > 0;
|
||||
};
|
||||
if (categoryId !== 'neu' && categoryId !== 'bald' && !isValidJtlCategoryId(enhancedResponse.dataParam)) {
|
||||
try {
|
||||
const currentLanguage = this.props.languageContext?.currentLanguage || this.props.i18n?.language || 'de';
|
||||
const categoryTreeCache = window.categoryService.getSync(209, currentLanguage);
|
||||
if (categoryTreeCache) {
|
||||
const targetCategory = typeof categoryId === 'string'
|
||||
? this.findCategoryBySeoName(categoryTreeCache, categoryId)
|
||||
: this.findCategoryById(categoryTreeCache, categoryId);
|
||||
if (targetCategory && typeof targetCategory.id === 'number' && targetCategory.id > 0) {
|
||||
enhancedResponse.dataParam = targetCategory.id;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error resolving dataParam from category tree:', err);
|
||||
}
|
||||
}
|
||||
|
||||
this.processData(enhancedResponse);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,32 @@ import React, { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
||||
import Filter from './Filter.js';
|
||||
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { setSessionSetting, removeSessionSetting, clearAllSessionSettings } from '../utils/sessionStorage.js';
|
||||
import { withI18n } from '../i18n/withTranslation.js';
|
||||
import {
|
||||
isPushApiSupported,
|
||||
fetchPushConfiguration,
|
||||
registerPushServiceWorker,
|
||||
ensurePushSubscription,
|
||||
categoryPushStatus,
|
||||
categoryPushSubscribe,
|
||||
categoryPushUnsubscribe,
|
||||
parseSubscribedStatus,
|
||||
parseSuccess,
|
||||
} from '../utils/categoryPush.js';
|
||||
|
||||
const isNew = (neu) => neu && (new Date().getTime() - new Date(neu).getTime() < 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
/** Category push subscribe UI only when the category has more than this many articles. */
|
||||
const MIN_ARTICLES_FOR_CATEGORY_PUSH = 10;
|
||||
|
||||
// HOC to provide router props to class components
|
||||
const withRouter = (ClassComponent) => {
|
||||
return (props) => {
|
||||
@@ -39,6 +58,10 @@ class ProductFilters extends Component {
|
||||
uniqueManufacturerArray,
|
||||
attributeGroups,
|
||||
manufacturerImages: new Map(), // id (number) → object URL
|
||||
pushInteractive: false,
|
||||
pushSubscribed: false,
|
||||
pushBusy: false,
|
||||
pushError: null,
|
||||
};
|
||||
|
||||
this._manufacturerImageUrls = []; // track for cleanup
|
||||
@@ -48,6 +71,7 @@ class ProductFilters extends Component {
|
||||
this.adjustPaperHeight();
|
||||
window.addEventListener('resize', this.adjustPaperHeight);
|
||||
this._loadManufacturerImages();
|
||||
this.refreshCategoryPushStatus();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -103,8 +127,135 @@ class ProductFilters extends Component {
|
||||
const attributeGroups = this._getAttributeGroups(this.props.attributes);
|
||||
this.setState({attributeGroups});
|
||||
}
|
||||
|
||||
const prevCount = prevProps.products?.length || 0;
|
||||
const nextCount = this.props.products?.length || 0;
|
||||
if (
|
||||
prevProps.dataParam !== this.props.dataParam ||
|
||||
prevProps.dataType !== this.props.dataType ||
|
||||
prevProps.params?.categoryId !== this.props.params?.categoryId ||
|
||||
prevCount !== nextCount
|
||||
) {
|
||||
this.refreshCategoryPushStatus();
|
||||
}
|
||||
}
|
||||
|
||||
kKategorieNumber = () => {
|
||||
const { dataParam, dataType } = this.props;
|
||||
if (dataType !== 'category') return null;
|
||||
if (dataParam == null || dataParam === '') return null;
|
||||
const n = typeof dataParam === 'number' ? dataParam : parseInt(String(dataParam), 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
};
|
||||
|
||||
shouldShowCategoryPush = () =>
|
||||
(this.props.products?.length || 0) > MIN_ARTICLES_FOR_CATEGORY_PUSH;
|
||||
|
||||
refreshCategoryPushStatus = async () => {
|
||||
const kKat = this.kKategorieNumber();
|
||||
if (!kKat || !this.shouldShowCategoryPush() || !isPushApiSupported()) {
|
||||
this.setState({
|
||||
pushInteractive: false,
|
||||
pushSubscribed: false,
|
||||
pushError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cfg = await fetchPushConfiguration();
|
||||
if (!cfg.configured || !cfg.publicKey) {
|
||||
this.setState({ pushInteractive: false });
|
||||
return;
|
||||
}
|
||||
await registerPushServiceWorker();
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
this.setState({
|
||||
pushInteractive: true,
|
||||
pushSubscribed: false,
|
||||
pushError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const statusData = await categoryPushStatus(kKat, subscription.endpoint);
|
||||
this.setState({
|
||||
pushInteractive: true,
|
||||
pushSubscribed: parseSubscribedStatus(statusData),
|
||||
pushError: null,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('ProductFilters: category push init failed', e);
|
||||
this.setState({ pushInteractive: false });
|
||||
}
|
||||
};
|
||||
|
||||
handleCategoryPushClick = async () => {
|
||||
const t = this.props.t;
|
||||
if (!this.state.pushInteractive || this.state.pushBusy) return;
|
||||
const kKat = this.kKategorieNumber();
|
||||
if (!kKat || !this.shouldShowCategoryPush()) return;
|
||||
this.setState({ pushBusy: true, pushError: null });
|
||||
try {
|
||||
if (this.state.pushSubscribed) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
this.setState({ pushSubscribed: false, pushBusy: false });
|
||||
return;
|
||||
}
|
||||
const res = await categoryPushUnsubscribe(subscription.endpoint);
|
||||
if (parseSuccess(res)) {
|
||||
this.setState({ pushSubscribed: false });
|
||||
} else {
|
||||
this.setState({
|
||||
pushError:
|
||||
res?.message ||
|
||||
res?.error ||
|
||||
(t ? t('productDialogs.pushNotifyError') : ''),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== 'granted') {
|
||||
this.setState({
|
||||
pushError: t ? t('productDialogs.pushNotifyPermissionDenied') : '',
|
||||
pushBusy: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const cfg = await fetchPushConfiguration();
|
||||
if (!cfg.configured || !cfg.publicKey) {
|
||||
this.setState({
|
||||
pushError: t ? t('productDialogs.pushNotifyServerDisabled') : '',
|
||||
pushBusy: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await registerPushServiceWorker();
|
||||
const subscription = await ensurePushSubscription(cfg.publicKey);
|
||||
const res = await categoryPushSubscribe(kKat, subscription);
|
||||
if (parseSuccess(res)) {
|
||||
this.setState({ pushSubscribed: true });
|
||||
} else {
|
||||
this.setState({
|
||||
pushError:
|
||||
res?.message ||
|
||||
res?.error ||
|
||||
(t ? t('productDialogs.pushNotifyError') : ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('ProductFilters: category push', e);
|
||||
this.setState({
|
||||
pushError: e.message || (t ? t('productDialogs.pushNotifyError') : ''),
|
||||
});
|
||||
} finally {
|
||||
this.setState({ pushBusy: false });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
adjustPaperHeight = () => {
|
||||
// Skip height adjustment on xs screens
|
||||
@@ -201,6 +352,25 @@ class ProductFilters extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const kKategorie = this.kKategorieNumber();
|
||||
const showCategoryPush = kKategorie && this.shouldShowCategoryPush();
|
||||
const {
|
||||
pushInteractive,
|
||||
pushSubscribed,
|
||||
pushBusy,
|
||||
pushError,
|
||||
} = this.state;
|
||||
const pushDisabledHint =
|
||||
showCategoryPush && !pushInteractive && !pushBusy
|
||||
? isPushApiSupported()
|
||||
? this.props.t
|
||||
? this.props.t('productDialogs.pushNotifyServerDisabled')
|
||||
: ''
|
||||
: this.props.t
|
||||
? this.props.t('filters.notifyNewArticlesBrowserUnsupported')
|
||||
: 'Ihr Browser unterstützt keine Push-Benachrichtigungen.'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -231,17 +401,91 @@ class ProductFilters extends Component {
|
||||
>
|
||||
|
||||
{this.props.dataType == 'category' && (
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h1"
|
||||
sx={{
|
||||
mb: 4,
|
||||
fontFamily: 'SwashingtonCP',
|
||||
color: 'primary.main'
|
||||
}}
|
||||
>
|
||||
{this.props.categoryName}
|
||||
</Typography>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h1"
|
||||
sx={{
|
||||
mb: showCategoryPush ? 1.5 : 4,
|
||||
fontFamily: 'SwashingtonCP',
|
||||
color: 'primary.main',
|
||||
}}
|
||||
>
|
||||
{this.props.categoryName}
|
||||
</Typography>
|
||||
{showCategoryPush && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Tooltip title={pushDisabledHint} arrow>
|
||||
<span style={{ display: 'block', width: '100%' }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={this.handleCategoryPushClick}
|
||||
disabled={!pushInteractive || pushBusy}
|
||||
startIcon={
|
||||
pushBusy ? (
|
||||
<CircularProgress size={14} sx={{ color: 'inherit' }} />
|
||||
) : pushSubscribed ? (
|
||||
<NotificationsActiveIcon sx={{ fontSize: 18, color: '#2e7d32' }} />
|
||||
) : (
|
||||
<NotificationsIcon sx={{ fontSize: 18, color: 'rgba(0,0,0,0.65)' }} />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
lineHeight: 1.2,
|
||||
backgroundColor: '#fff',
|
||||
color: 'text.primary',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
boxShadow: 'none',
|
||||
whiteSpace: 'normal',
|
||||
textAlign: 'center',
|
||||
py: 0.4,
|
||||
px: 0.75,
|
||||
minHeight: 28,
|
||||
'& .MuiButton-label': {
|
||||
whiteSpace: 'normal',
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
'& .MuiButton-startIcon': {
|
||||
mr: 0.5,
|
||||
'& > *:nth-of-type(1)': { fontSize: 18 },
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'grey.50',
|
||||
borderColor: 'divider',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
backgroundColor: '#fff',
|
||||
color: 'action.disabled',
|
||||
borderColor: 'action.disabledBackground',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{this.props.t
|
||||
? this.props.t('filters.notifyNewArticles')
|
||||
: 'Bei neuen Artikeln benachrichtigen'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{pushError && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="error"
|
||||
sx={{ display: 'block', mt: 0.5, textAlign: 'center' }}
|
||||
>
|
||||
{pushError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user