diff --git a/.eslintrc.js b/.eslintrc.js index 2f39755579..009b4d2b8f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -96,7 +96,8 @@ module.exports = { extends: [ "eslint:recommended", "plugin:react/recommended", - "prettier" - , "plugin:jsx-a11y/strict" + "prettier", + "plugin:jsx-a11y/strict", + "plugin:react-hooks/recommended" ] } diff --git a/.github/workflows/test-e2e.yaml b/.github/workflows/test-e2e.yaml index 836214419e..f8caa18f7d 100644 --- a/.github/workflows/test-e2e.yaml +++ b/.github/workflows/test-e2e.yaml @@ -5,7 +5,11 @@ on: branches: - 'master' pull_request: - types: [ready_for_review] + types: + - opened + - reopened + - synchronize + - ready_for_review jobs: e2e: @@ -18,6 +22,7 @@ jobs: matrix: containers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] name: Testing e2e in worker ${{ matrix.containers }} + if: github.event.pull_request.draft == false steps: - name: Checkout repository uses: actions/checkout@v3.5.2 diff --git a/nosgestesclimat b/nosgestesclimat index 3c21fa0e8e..27020ecab8 160000 --- a/nosgestesclimat +++ b/nosgestesclimat @@ -1 +1 @@ -Subproject commit 3c21fa0e8e936158e47a5e624bfa5a4cb8f143c0 +Subproject commit 27020ecab80e4fe3a406aef3d01981fad8846520 diff --git a/package.json b/package.json index 2c03226b8b..2655b4e2a2 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "eslint": "^8.19.0", "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-react": "^7.30.1", + "eslint-plugin-react-hooks": "^4.6.0", "eslint-webpack-plugin": "^3.2.0", "file-loader": "^6.2.0", "i18next-parser": "^6.5.0", diff --git a/source/Provider.tsx b/source/Provider.tsx index 73bd0ec26a..93629e2b7c 100644 --- a/source/Provider.tsx +++ b/source/Provider.tsx @@ -9,9 +9,9 @@ import { BrowserRouter } from 'react-router-dom' import reducers, { RootState } from 'Reducers/rootReducer' import { applyMiddleware, compose, createStore, Middleware, Store } from 'redux' import thunk from 'redux-thunk' -import { TrackerProvider } from './contexts/TrackerContext' +import { MatomoProvider } from './contexts/MatomoContext' import RulesProvider from './RulesProvider' -import { inIframe } from './utils' +import { getIsIframe } from './utils' declare global { interface Window { @@ -21,7 +21,11 @@ declare global { const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose -if (NODE_ENV === 'production' && 'serviceWorker' in navigator && !inIframe()) { +if ( + NODE_ENV === 'production' && + 'serviceWorker' in navigator && + !getIsIframe() +) { window.addEventListener('load', () => { navigator.serviceWorker .register('/sw.js') @@ -74,9 +78,9 @@ export default function Provider({ return ( // If IE < 11 display nothing - - - + + + @@ -90,8 +94,8 @@ export default function Provider({ - - - + + + ) } diff --git a/source/actions/actions.ts b/source/actions/actions.ts index 0ebe7db1b9..3617cc0ebe 100644 --- a/source/actions/actions.ts +++ b/source/actions/actions.ts @@ -1,4 +1,5 @@ import { RootState, SimulationConfig } from 'Reducers/rootReducer' +import { AnyAction } from 'redux' import { ThunkAction } from 'redux-thunk' import { DottedName } from 'Rules' import { Localisation } from '../components/localisation/utils' @@ -253,3 +254,10 @@ export const updateAmortissementAvion = (amortissementAvionObject: Object) => ({ type: 'SET_AMORTISSEMENT', amortissementAvionObject, }) + +export const updateEventsSent = (eventSent: { + [key: string]: boolean +}): AnyAction => ({ + type: 'UPDATE_EVENTS_SENT', + eventSent, +}) diff --git a/source/analytics/matomo-events.ts b/source/analytics/matomo-events.ts new file mode 100644 index 0000000000..9ebce55344 --- /dev/null +++ b/source/analytics/matomo-events.ts @@ -0,0 +1,190 @@ +import { DottedName } from 'Rules' + +/* + * Matomo events + * https://matomo.org/docs/event-tracking/ + * [ + * 'trackEvent', // Type de l'évènement + * 'Category' (string), // Catégorie de l'évènement à rattacher à son contexte (cf. exemples ci-dessous) + * 'Action' (string), // Action concrête mesurée (clic sur machin, submit de tel truc, etc.) + * 'Name' (string), // Nom de l'évènement (optionnel) si besoin de détailier l'action + * 'Value' (number) // Une valeur numérique (optionnelle) + * ] + */ + +// Partage +export const getMatomoEventShareMobile = (score: number) => [ + 'trackEvent', + 'Partage page de fin', + 'Clic bouton "Partager mes résultats" sur mobile', + null, + score, +] +export const getMatomoEventShareDesktop = (score: number) => [ + 'trackEvent', + 'Partage page de fin', + 'Clic bouton "Partager mes résultats" sur desktop', + null, + score, +] + +// Formulaire +export const getMatomoEventClickNextQuestion = (currentQuestion: string) => [ + 'trackEvent', + 'Formulaire', + 'Clic bouton "Suivant"', + currentQuestion, +] +export const getMatomoEventClickDontKnow = (currentQuestion: string) => [ + 'trackEvent', + 'Formulaire', + 'Clic bouton "Je ne sais pas"', + currentQuestion, +] +export const getMatomoEventClickHelp = (dottedName: DottedName) => [ + 'trackEvent', + 'help', + dottedName, +] +export const matomoEventKilometerHelp = [ + 'trackEvent', + 'Aide saisie km', + 'Ajout trajet km voiture', +] +export const matomoEventKilometerHelpClickOpen = [ + 'trackEvent', + 'Aide saisie km', + 'Ouvre aide à la saisie km voiture', +] +export const matomoEventKilometerHelpClickClose = [ + 'trackEvent', + 'Aide saisie km', + 'Ferme aide à la saisie km voiture', +] +export const getMatomoEventAmortissement = (dottedName: DottedName) => [ + 'trackEvent', + 'Formulaire', + 'Utilisation amortissement avion', + dottedName, +] + +// Change Region +export const getMatomoEventChangeRegion = (code: string) => [ + 'trackEvent', + 'I18N', + 'Clic bannière localisation', + code, +] + +// Iframe +export const getMatomoEventVisitViaIframe = (url: string) => [ + 'trackEvent', + 'iframe', + 'visites via iframe', + url, +] +export const matomoEventInteractionIframe = [ + 'trackEvent', + 'iframe', + 'interaction avec iframe', +] + +// Mode groupe +export const matomoEventModeGroupeFiltres = [ + 'trackEvent', + 'Mode Groupe', + 'Ouvre filtres', +] +export const getMatomoEventModeGroupeRealtimeActivation = ( + isRealTime: boolean +) => [ + 'trackEvent', + 'Mode Groupe', + isRealTime + ? 'Désactivation du mode temps réel' + : 'Activation du mode temps réel', +] +export const getMatomoEventModeGroupeRoomCreation = (mode: string) => [ + 'trackEvent', + 'Mode Groupe', + 'Création salle', + mode, +] +export const matomoEventModeGroupeCTAStart = [ + 'trackEvent', + 'Mode Groupe', + 'Clic CTA accueil', +] + +// Funnel +export const matomoEventParcoursTestStart = [ + 'trackEvent', + 'NGC', + 'Clic CTA accueil', +] +export const matomoEventParcoursTestReprendre = [ + 'trackEvent', + 'NGC', + 'Clic CTA accueil : Reprendre mon test', +] +export const getMatomoEventParcoursTestTutorialProgress = ( + last: boolean, + index: number +) => ['trackEvent', 'testIntro', last ? 'Terminer' : `diapo ${index} passée`] +export const matomoEventParcoursTestSkipTutorial = [ + 'trackEvent', + 'testIntro', + 'tuto passé', +] +export const matomoEventFirstAnswer = [ + 'trackEvent', + 'NGC', + '1ère réponse au bilan', +] +export const getMatomoEventParcoursTestCategoryStarted = (category: string) => [ + 'trackEvent', + 'NGC', + 'Catégorie démarrée', + category, +] +export const matomoEvent50PercentProgress = [ + 'trackEvent', + 'NGC', + 'Progress > 50%', +] +export const matomoEvent90PercentProgress = [ + 'trackEvent', + 'NGC', + 'Progress > 90%', +] +export const getMatomoEventParcoursTestOver = (bilan: number | undefined) => [ + 'trackEvent', + 'NGC', + 'A terminé la simulation', + null, + bilan || '', +] +export const matomoEventClickBanner = [ + 'trackEvent', + 'NGC', + 'Clic explication score', +] +export const matomoEventSwipeEndPage = [ + 'trackEvent', + 'NGC', + 'Swipe page de fin', +] +export const getMatomoEventClickActionButtonEndPage = ( + score: string | number +) => ['trackEvent', 'NGC', 'Clic bouton action page /fin', null, score] + +// Actions +export const getMatomoEventActionRejected = ( + dottedName: DottedName, + nodeValue: string +) => ['trackEvent', '/actions', 'Action rejetée', dottedName, nodeValue] + +export const getMatomoEventActionAccepted = ( + dottedName: DottedName, + nodeValue: string +) => ['trackEvent', '/actions', 'Action sélectionnée', dottedName, nodeValue] diff --git a/source/components/Feedback/FeedbackForm.tsx b/source/components/Feedback/FeedbackForm.tsx index 70f31f7b46..75a584bf05 100644 --- a/source/components/Feedback/FeedbackForm.tsx +++ b/source/components/Feedback/FeedbackForm.tsx @@ -60,7 +60,6 @@ export default function FeedbackForm({ onCancel }: Props) { script.src = 'https://mon-entreprise.zammad.com/assets/form/form.js' document.body.appendChild(script) }, 100) - // tracker.push(['trackEvent', 'Feedback', 'written feedback submitted']) }, []) return ( diff --git a/source/components/Feedback/PageFeedback.tsx b/source/components/Feedback/PageFeedback.tsx index 798f6b5e9c..94af9bcb51 100644 --- a/source/components/Feedback/PageFeedback.tsx +++ b/source/components/Feedback/PageFeedback.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useContext, useState } from 'react' import { Trans } from 'react-i18next' import { useLocation } from 'react-router-dom' -import { TrackerContext } from '../../contexts/TrackerContext' +import { MatomoContext } from '../../contexts/MatomoContext' import safeLocalStorage from '../../storage/safeLocalStorage' import './Feedback.css' import Form from './FeedbackForm' @@ -33,7 +33,7 @@ export default function PageFeedback({ customEventName, }: PageFeedbackProps) { const location = useLocation() - const tracker = useContext(TrackerContext) + const { trackEvent } = useContext(MatomoContext) const [state, setState] = useState({ showForm: false, showThanks: false, @@ -44,18 +44,11 @@ export default function PageFeedback({ }) const handleFeedback = useCallback(({ useful }: { useful: boolean }) => { - tracker.push([ - 'trackEvent', - 'Feedback', - useful ? 'positive rating' : 'negative rating', - location.pathname, - ]) const feedback = [ customEventName || 'rate page usefulness', location.pathname, useful ? 10 : 0.1, ] as [string, string, number] - tracker.push(['trackEvent', 'Feedback', ...feedback]) saveFeedbackOccurrenceInLocalStorage(feedback) setState({ showThanks: useful, @@ -65,7 +58,6 @@ export default function PageFeedback({ }, []) const handleErrorReporting = useCallback(() => { - tracker.push(['trackEvent', 'Feedback', 'report error', location.pathname]) setState({ ...state, showForm: true }) }, []) diff --git a/source/components/NewsBanner.tsx b/source/components/NewsBanner.tsx index d1cd8c2383..c5f93964f2 100644 --- a/source/components/NewsBanner.tsx +++ b/source/components/NewsBanner.tsx @@ -2,9 +2,9 @@ import { sortReleases } from 'Pages/news/NewsItem' import { Trans, useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import styled from 'styled-components' +import { usePersistingState } from '../hooks/usePersistState' import { getCurrentLangInfos } from '../locales/translation' import { capitalise0 } from '../utils' -import { usePersistingState } from './utils/persistState' export const localStorageKey = 'last-viewed-release' diff --git a/source/components/ProgressCircle.tsx b/source/components/ProgressCircle.tsx index 3efd2bac18..23ae10c259 100644 --- a/source/components/ProgressCircle.tsx +++ b/source/components/ProgressCircle.tsx @@ -1,8 +1,8 @@ import { motion, useMotionValue, useSpring } from 'framer-motion' import { useEffect } from 'react' import { useSelector } from 'react-redux' +import { useSimulationProgress } from '../hooks/useNextQuestion' import { configSelector } from '../selectors/simulationSelectors' -import { useSimulationProgress } from './utils/useNextQuestion' export default () => { const engineState = useSelector((state) => state.engineState) diff --git a/source/components/SearchButton.tsx b/source/components/SearchButton.tsx index 39a4368c9a..e9b6cc0901 100644 --- a/source/components/SearchButton.tsx +++ b/source/components/SearchButton.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { Trans } from 'react-i18next' import { Navigate } from 'react-router' -import useKeypress from './utils/useKeyPress' +import useKeypress from '../hooks/useKeyPress' type SearchButtonProps = { invisibleButton?: boolean diff --git a/source/components/SessionBar.tsx b/source/components/SessionBar.tsx index 4d436dbcf0..735f9be335 100644 --- a/source/components/SessionBar.tsx +++ b/source/components/SessionBar.tsx @@ -8,12 +8,12 @@ import { RootState } from 'Reducers/rootReducer' import { answeredQuestionsSelector } from 'Selectors/simulationSelectors' import styled from 'styled-components' import { resetLocalisation } from '../actions/actions' +import { usePersistingState } from '../hooks/usePersistState' import { useTestCompleted } from '../selectors/simulationSelectors' import { enquêteSelector } from '../sites/publicodes/enquête/enquêteSelector' import { omit } from '../utils' import CardGameIcon from './CardGameIcon' import ProgressCircle from './ProgressCircle' -import { usePersistingState } from './utils/persistState' const ActionsInteractiveIcon = () => { const actionChoices = useSelector((state) => state.actionChoices), diff --git a/source/components/ShareButton.tsx b/source/components/ShareButton.tsx index a7f150c83a..9be32381c6 100644 --- a/source/components/ShareButton.tsx +++ b/source/components/ShareButton.tsx @@ -1,12 +1,30 @@ import { useContext, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { TrackerContext } from '../contexts/TrackerContext' +import { + getMatomoEventShareDesktop, + getMatomoEventShareMobile, +} from '../analytics/matomo-events' +import { MatomoContext } from '../contexts/MatomoContext' import ShareButtonIcon from './ShareButtonIcon' const eventData = ['trackEvent', 'partage', 'Partage page fin'] -export default ({ text, url, title, color, label, score }) => { - const tracker = useContext(TrackerContext) +export default ({ + text, + url, + title, + color, + label, + score, +}: { + text: string + url: string + title: string + color: string + label: string + score: number +}) => { + const { trackEvent } = useContext(MatomoContext) const { t } = useTranslation() return navigator.share ? ( @@ -14,7 +32,7 @@ export default ({ text, url, title, color, label, score }) => { color={color} title={t('Cliquez pour partager le lien')} onClick={() => { - tracker.push([...eventData, 'mobile', score]) + trackEvent(getMatomoEventShareMobile(score)) navigator .share({ text, url, title, color, label }) .then(() => console.log('Successful share')) @@ -32,7 +50,7 @@ export default ({ text, url, title, color, label, score }) => { color, text, url, - trackEvent: () => tracker.push([...eventData, 'bureau', score]), + trackEventDesktop: () => trackEvent(getMatomoEventShareDesktop(score)), }} /> ) @@ -44,7 +62,19 @@ const copyToClipboardAsync = (str) => { return Promise.reject('The Clipboard API is not available.') } -export const DesktopShareButton = ({ label, text, color, url, trackEvent }) => { +export const DesktopShareButton = ({ + label, + text, + color, + url, + trackEventDesktop, +}: { + label: string + text: string + color: string + url: string + trackEventDesktop: () => void +}) => { const [copySuccess, setCopySuccess] = useState(false) const { t } = useTranslation() @@ -57,7 +87,7 @@ ${decodeURIComponent(url)}` title={t('Cliquez pour partager le lien')} color={color} onClick={() => { - trackEvent() + trackEventDesktop() copyToClipboardAsync(clipboardText).then( () => { /* clipboard successfully set */ diff --git a/source/components/conversation/CategoryRespiration.js b/source/components/conversation/CategoryRespiration.js index e4b3b73453..b26b9b47ad 100644 --- a/source/components/conversation/CategoryRespiration.js +++ b/source/components/conversation/CategoryRespiration.js @@ -1,8 +1,10 @@ import { motion } from 'framer-motion' -import { useEffect, useRef } from 'react' +import { useContext, useEffect, useRef } from 'react' import { Trans } from 'react-i18next' +import { getMatomoEventParcoursTestCategoryStarted } from '../../analytics/matomo-events' +import { MatomoContext } from '../../contexts/MatomoContext' +import useKeypress from '../../hooks/useKeyPress' import SafeCategoryImage from '../SafeCategoryImage' -import useKeypress from '../utils/useKeyPress' // Naive implementation - in reality would want to attach // a window or resize listener. Also use state/layoutEffect instead of ref/effect @@ -43,6 +45,8 @@ export default ({ dismiss, questionCategory }) => { const containerRef = useRef(null) const { width } = useDimensions(containerRef) + const { trackEvent } = useContext(MatomoContext) + useKeypress('Enter', false, dismiss, 'keyup', []) return ( @@ -100,7 +104,14 @@ export default ({ dismiss, questionCategory }) => { diff --git a/source/components/conversation/Conversation.tsx b/source/components/conversation/Conversation.tsx index 3552e39e2d..7d018df2ce 100644 --- a/source/components/conversation/Conversation.tsx +++ b/source/components/conversation/Conversation.tsx @@ -5,7 +5,6 @@ import RuleInput, { } from 'Components/conversation/RuleInput' import Notifications, { getCurrentNotification } from 'Components/Notifications' import { EngineContext } from 'Components/utils/EngineContext' -import { useNextQuestions } from 'Components/utils/useNextQuestion' import { motion } from 'framer-motion' import React, { useContext, useEffect, useState } from 'react' import { Trans } from 'react-i18next' @@ -20,8 +19,20 @@ import { skipTutorial, validateWithDefaultValue, } from '../../actions/actions' +import { + getMatomoEventClickDontKnow, + getMatomoEventParcoursTestOver, + matomoEvent50PercentProgress, + matomoEvent90PercentProgress, + matomoEventFirstAnswer, +} from '../../analytics/matomo-events' import Meta from '../../components/utils/Meta' -import { TrackerContext } from '../../contexts/TrackerContext' +import { MatomoContext } from '../../contexts/MatomoContext' +import useKeypress from '../../hooks/useKeyPress' +import { + useNextQuestions, + useSimulationProgress, +} from '../../hooks/useNextQuestion' import { isPersonaSelector, objectifsSelector, @@ -30,8 +41,6 @@ import { enquêteSelector } from '../../sites/publicodes/enquête/enquêteSelect import { sortBy, useQuery } from '../../utils' import { questionCategoryName, splitName, title } from '../publicodesUtils' import SafeCategoryImage from '../SafeCategoryImage' -import useKeypress from '../utils/useKeyPress' -import { useSimulationProgress } from '../utils/useNextQuestion' import Aide from './Aide' import CategoryRespiration from './CategoryRespiration' import './conversation.css' @@ -56,7 +65,7 @@ export default function Conversation({ const nextQuestions = useNextQuestions() const situation = useSelector(situationSelector) const previousAnswers = useSelector(answeredQuestionsSelector) - const tracker = useContext(TrackerContext) + const { trackEvent } = useContext(MatomoContext) const objectifs = useSelector(objectifsSelector) const previousSimulation = useSelector((state) => state.previousSimulation) // orderByCategories is the list of categories, ordered by decreasing nodeValue @@ -92,7 +101,7 @@ export default function Conversation({ const unfoldedStep = useSelector((state) => state.simulation.unfoldedStep) const isMainSimulation = objectifs.length === 1 && objectifs[0] === 'bilan', - currentQuestion = !isMainSimulation + currentQuestion: string = !isMainSimulation ? nextQuestions[0] : focusedCategory ? sortedQuestions[0] @@ -102,34 +111,10 @@ export default function Conversation({ const tutorials = useSelector((state) => state.tutorials) const tracking = useSelector((state) => state.tracking) - - const enquête = useSelector(enquêteSelector) - - useEffect(() => { - if (!tracking.firstQuestionEventFired && previousAnswers.length >= 1) { - console.log('1ère réponse au bilan') - tracker.push(['trackEvent', 'NGC', '1ère réponse au bilan']) - dispatch(setTrackingVariable('firstQuestionEventFired', true)) - } - }, [tracker, previousAnswers]) - const progress = useSimulationProgress() const isPersona = useSelector(isPersonaSelector) - useEffect(() => { - // This will help you judge if the "A terminé la simulation" event has good numbers - if (!tracking.progress90EventFired && progress > 0.9 && !isPersona) { - console.log('90% réponse au bilan') - tracker.push(['trackEvent', 'NGC', 'Progress > 90%']) - dispatch(setTrackingVariable('progress90EventFired', true)) - } - - if (!tracking.progress50EventFired && progress > 0.5 && !isPersona) { - console.log('50% réponse au bilan') - tracker.push(['trackEvent', 'NGC', 'Progress > 50%']) - dispatch(setTrackingVariable('progress50EventFired', true)) - } - }, [tracker, progress]) + const enquête = useSelector(enquêteSelector) useEffect(() => { // This hook lets the user click on the "next" button. Without it, the conversation switches to the next question as soon as an answer is provided. @@ -294,33 +279,74 @@ export default function Conversation({ 'keydown', [] ) - const endEventFired = tracking.endEventFired + const noQuestionsLeft = !nextQuestions.length - const bilan = Math.round(engine.evaluate('bilan').nodeValue) + const endEventFired = tracking.endEventFired + + const questionCategory = + orderByCategories && + orderByCategories.find( + ({ dottedName }) => dottedName === questionCategoryName(currentQuestion) + ) + + useEffect(() => { + if ( + !tracking.firstQuestionEventFired && + previousAnswers.length >= 1 && + !isPersona + ) { + trackEvent(matomoEventFirstAnswer) + dispatch(setTrackingVariable('firstQuestionEventFired', true)) + } + }, [ + dispatch, + previousAnswers, + trackEvent, + tracking.firstQuestionEventFired, + isPersona, + ]) + + useEffect(() => { + // This will help you judge if the "A terminé la simulation" event has good numbers + if (!tracking.progress90EventFired && progress > 0.9 && !isPersona) { + trackEvent(matomoEvent90PercentProgress) + dispatch(setTrackingVariable('progress90EventFired', true)) + } + + if (!tracking.progress50EventFired && progress > 0.5 && !isPersona) { + trackEvent(matomoEvent50PercentProgress) + dispatch(setTrackingVariable('progress50EventFired', true)) + } + }, [ + dispatch, + progress, + trackEvent, + tracking.progress50EventFired, + tracking.progress90EventFired, + isPersona, + ]) + + const bilan = engine + ? Math.round( + parseFloat((engine?.evaluate('bilan')?.nodeValue as string) || '') + ) + : undefined useEffect(() => { if (!endEventFired && noQuestionsLeft && !isPersona) { - tracker.push([ - 'trackEvent', - 'NGC', - 'A terminé la simulation', - 'bilan', - bilan, - ]) - dispatch(setTrackingVariable('endEventFired', true)) + // Cannot be sent several times, trackEvent filters duplicates + trackEvent(getMatomoEventParcoursTestOver(bilan)) } - }, [endEventFired, noQuestionsLeft]) + }, [noQuestionsLeft, bilan, trackEvent, endEventFired, isPersona]) if (noQuestionsLeft) { return } - const questionCategory = - orderByCategories && - orderByCategories.find( - ({ dottedName }) => dottedName === questionCategoryName(currentQuestion) - ) + if (noQuestionsLeft) { + return + } const isCategoryFirstQuestion = questionCategory && @@ -476,7 +502,7 @@ export default function Conversation({ ) : (