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:
sebseb7
2026-03-26 20:51:28 +01:00
parent 4b634414e5
commit de8e59f1bb
26 changed files with 435 additions and 45 deletions

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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>
)}

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "لكل صفحة",
"availability": "التوفر",
"manufacturer": "المصنّع",
"all": "الكل"
"all": "الكل",
"notifyNewArticles": "إشعار عند توفر منتجات جديدة",
"notifyNewArticlesBrowserUnsupported": "المتصفح لا يدعم إشعارات الدفع."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "на страница",
"availability": "Наличност",
"manufacturer": "Производител",
"all": "Всички"
"all": "Всички",
"notifyNewArticles": "Уведомявай ме за нови продукти",
"notifyNewArticlesBrowserUnsupported": "Вашият браузър не поддържа push известия."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "na stránku",
"availability": "Dostupnost",
"manufacturer": "Výrobce",
"all": "Vše"
"all": "Vše",
"notifyNewArticles": "Upozornit na nové produkty",
"notifyNewArticlesBrowserUnsupported": "Váš prohlížeč nepodporuje push oznámení."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "pro Seite",
"availability": "Verfügbarkeit",
"manufacturer": "Hersteller",
"all": "Alle"
"all": "Alle",
"notifyNewArticles": "Bei neuen Artikeln benachrichtigen",
"notifyNewArticlesBrowserUnsupported": "Ihr Browser unterstützt keine Push-Benachrichtigungen."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "ανά σελίδα",
"availability": "Διαθεσιμότητα",
"manufacturer": "Κατασκευαστής",
"all": "Όλα"
"all": "Όλα",
"notifyNewArticles": "Ειδοποίησέ με για νέα προϊόντα",
"notifyNewArticlesBrowserUnsupported": "Ο φυλλομετρητής σας δεν υποστηρίζει push ειδοποιήσεις."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "per page", // pro Seite
"availability": "Availability", // Verfügbarkeit
"manufacturer": "Manufacturer", // Hersteller
"all": "All" // Alle
"all": "All", // Alle
"notifyNewArticles": "Notify me about new articles",
"notifyNewArticlesBrowserUnsupported": "Your browser does not support push notifications."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "por página",
"availability": "Disponibilidad",
"manufacturer": "Fabricante",
"all": "Todos"
"all": "Todos",
"notifyNewArticles": "Notificarme sobre artículos nuevos",
"notifyNewArticlesBrowserUnsupported": "Su navegador no admite notificaciones push."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "par page",
"availability": "Disponibilité",
"manufacturer": "Fabricant",
"all": "Tous"
"all": "Tous",
"notifyNewArticles": "Être notifié des nouveaux articles",
"notifyNewArticlesBrowserUnsupported": "Votre navigateur ne prend pas en charge les notifications push."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "po stranici",
"availability": "Dostupnost",
"manufacturer": "Proizvođač",
"all": "Sve"
"all": "Sve",
"notifyNewArticles": "Obavijesti me o novim artiklima",
"notifyNewArticlesBrowserUnsupported": "Vaš preglednik ne podržava push obavijesti."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "oldalanként",
"availability": "Elérhetőség",
"manufacturer": "Gyártó",
"all": "Összes"
"all": "Összes",
"notifyNewArticles": "Értesítés új termékekről",
"notifyNewArticlesBrowserUnsupported": "A böngésző nem támogatja a push értesítéseket."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "per pagina",
"availability": "Disponibilità",
"manufacturer": "Produttore",
"all": "Tutti"
"all": "Tutti",
"notifyNewArticles": "Avvisami sui nuovi articoli",
"notifyNewArticlesBrowserUnsupported": "Il tuo browser non supporta le notifiche push."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "na stronę",
"availability": "Dostępność",
"manufacturer": "Producent",
"all": "Wszystkie"
"all": "Wszystkie",
"notifyNewArticles": "Powiadamiaj o nowych produktach",
"notifyNewArticlesBrowserUnsupported": "Twoja przeglądarka nie obsługuje powiadomień push."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "pe pagină",
"availability": "Disponibilitate",
"manufacturer": "Producător",
"all": "Toate"
"all": "Toate",
"notifyNewArticles": "Anunță-mă despre articole noi",
"notifyNewArticlesBrowserUnsupported": "Browserul nu acceptă notificări push."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "на странице",
"availability": "Наличие",
"manufacturer": "Производитель",
"all": "Все"
"all": "Все",
"notifyNewArticles": "Уведомлять о новых товарах",
"notifyNewArticlesBrowserUnsupported": "Ваш браузер не поддерживает push-уведомления."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "na stránku",
"availability": "Dostupnosť",
"manufacturer": "Výrobca",
"all": "Všetko"
"all": "Všetko",
"notifyNewArticles": "Upozorni ma na nové produkty",
"notifyNewArticlesBrowserUnsupported": "Váš prehliadač nepodporuje push notifikácie."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "na stran",
"availability": "Razpoložljivost",
"manufacturer": "Proizvajalec",
"all": "Vse"
"all": "Vse",
"notifyNewArticles": "Obvesti me o novih izdelkih",
"notifyNewArticlesBrowserUnsupported": "Vaš brskalnik ne podpira potisnih obvestil."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "për faqe",
"availability": "Disponueshmëria",
"manufacturer": "Prodhuesi",
"all": "Të gjitha"
"all": "Të gjitha",
"notifyNewArticles": "Njoftom për artikuj të rinj",
"notifyNewArticlesBrowserUnsupported": "Shfletuesi juaj nuk mbështet njoftimet push."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "po stranici",
"availability": "Dostupnost",
"manufacturer": "Proizvođač",
"all": "Sve"
"all": "Sve",
"notifyNewArticles": "Obavesti me o novim artiklima",
"notifyNewArticlesBrowserUnsupported": "Vaš pregledač ne podržava push obaveštenja."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "per sida",
"availability": "Tillgänglighet",
"manufacturer": "Tillverkare",
"all": "Alla"
"all": "Alla",
"notifyNewArticles": "Meddela mig om nya artiklar",
"notifyNewArticlesBrowserUnsupported": "Din webbläsare stöder inte push-notiser."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "sayfa başına",
"availability": "Stok Durumu",
"manufacturer": "Üretici",
"all": "Tümü"
"all": "Tümü",
"notifyNewArticles": "Yeni ürünlerden haberdar et",
"notifyNewArticlesBrowserUnsupported": "Tarayıcınız anlık bildirimleri desteklemiyor."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "на сторінку",
"availability": "Наявність",
"manufacturer": "Виробник",
"all": "Усі"
"all": "Усі",
"notifyNewArticles": "Повідомляти про нові товари",
"notifyNewArticlesBrowserUnsupported": "Ваш браузер не підтримує push-сповіщення."
};

View File

@@ -3,5 +3,7 @@ export default {
"perPage": "每页",
"availability": "库存情况",
"manufacturer": "制造商",
"all": "全部"
"all": "全部",
"notifyNewArticles": "新商品上架时通知我",
"notifyNewArticlesBrowserUnsupported": "您的浏览器不支持推送通知。"
};

57
src/utils/categoryPush.js Normal file
View File

@@ -0,0 +1,57 @@
/**
* Web Push for category “new articles” notifications — same VAPID/SW as article/pickup push.
*/
export {
fetchPushConfiguration,
registerPushServiceWorker,
ensurePushSubscription,
isPushApiSupported,
parseSubscribedStatus,
parseSuccess,
} from "./articlePush.js";
/**
* @param {number} kKategorie
* @param {string} endpoint
*/
export async function categoryPushStatus(kKategorie, endpoint) {
const res = await fetch("/api/category/push/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kKategorie, endpoint }),
});
const data = await res.json().catch(() => ({}));
return { ...data, httpOk: res.ok };
}
/**
* @param {number} kKategorie
* @param {PushSubscription} subscription
*/
export async function categoryPushSubscribe(kKategorie, subscription) {
const subJson = subscription.toJSON();
const res = await fetch("/api/category/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kKategorie,
subscription: subJson,
}),
});
const data = await res.json().catch(() => ({}));
return { ...data, httpOk: res.ok };
}
/**
* @param {string} endpoint
*/
export async function categoryPushUnsubscribe(endpoint) {
const res = await fetch("/api/category/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }),
});
const data = await res.json().catch(() => ({}));
return { ...data, httpOk: res.ok };
}