diff --git a/.env.example b/.env.example index c2af37afb..e4119b382 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,7 @@ FEATURE_SERVICEMAP_PAGE_TRACKING="false" # MATOMO_MOBILITY_DIMENSION_ID= # MATOMO_SENSES_DIMENSION_ID= # MATOMO_NO_RESULTS_DIMENSION_ID= +# MATOMO_ENABLED= # If false SHOW_AREA_SELECTION hides area related functionality. # Defaulting to true. diff --git a/config/default.js b/config/default.js index 3cc2a4a98..1a69b6453 100644 --- a/config/default.js +++ b/config/default.js @@ -1,7 +1,7 @@ export function getSettings() { if (typeof window !== 'undefined' && typeof window.nodeEnvSettings !== 'undefined') { - // Needed in browser run context - return window.nodeEnvSettings; + // Needed in browser run context + return window.nodeEnvSettings; } // This enables reading the environment variables from a .env file, // useful in a local development context. @@ -24,24 +24,24 @@ const version = getVersion(); if (typeof settings.PRODUCTION_PREFIX === 'undefined') { - // This is the correct way to set fail-safe defaults for - // configuration variables. - // - // This is because the defaults have to be set when in server - // context and they need to be assigned to process.env so that - // they get transferred to the client from the appropriate - // process.env values by the node server when rendering HTML. - settings.PRODUCTION_PREFIX = 'sm'; + // This is the correct way to set fail-safe defaults for + // configuration variables. + // + // This is because the defaults have to be set when in server + // context and they need to be assigned to process.env so that + // they get transferred to the client from the appropriate + // process.env values by the node server when rendering HTML. + settings.PRODUCTION_PREFIX = 'sm'; } if (typeof settings.INITIAL_MAP_POSITION === 'undefined') { - // If not set default to Helsinki - settings.INITIAL_MAP_POSITION = '60.170377597530016,24.941309323934886'; + // If not set default to Helsinki + settings.INITIAL_MAP_POSITION = '60.170377597530016,24.941309323934886'; } if (typeof settings.MAPS === 'undefined') { - // If not set default to Helsinki - settings.MAPS = 'servicemap,ortographic,accessible_map,guidemap,plainmap'; + // If not set default to Helsinki + settings.MAPS = 'servicemap,ortographic,accessible_map,guidemap,plainmap'; } if (typeof settings.CITIES === 'undefined') { @@ -133,6 +133,10 @@ if (typeof settings.SENTRY_DSN_CLIENT === 'undefined') { settings.SENTRY_DSN_CLIENT = false; } +if (settings.MATOMO_URL === 'undefined') { + settings.MATOMO_URL = undefined; +} + if (settings.MATOMO_MOBILITY_DIMENSION_ID === 'undefined') { settings.MATOMO_MOBILITY_DIMENSION_ID = undefined; } @@ -140,7 +144,6 @@ if (settings.MATOMO_MOBILITY_DIMENSION_ID === 'undefined') { if (settings.MATOMO_SENSES_DIMENSION_ID === 'undefined') { settings.MATOMO_SENSES_DIMENSION_ID = undefined; } - if (settings.MATOMO_NO_RESULTS_DIMENSION_ID === 'undefined') { settings.MATOMO_NO_RESULTS_DIMENSION_ID = undefined; } @@ -153,6 +156,10 @@ if (settings.MATOMO_SITE_ID === 'undefined') { settings.MATOMO_SITE_ID = undefined; } +if (settings.MATOMO_ENABLED === 'undefined') { + settings.MATOMO_ENABLED = undefined; +} + if (typeof settings.EMBEDDER_DOCUMENTATION_URL === 'undefined') { settings.EMBEDDER_DOCUMENTATION_URL = 'https://kaupunkialustana.hel.fi/palvelukartta/palvelukartan-upotusohjeet/'; } @@ -189,7 +196,7 @@ const municipalities = { */ const splitTripleIntoThreeLangs = (text) => ({ fi: text.split(',')[0], sv: text.split(',')[1], en: text.split(',')[2] }) -export default { +const defaultConfig = { "version": version.tag, "commit": version.commit, // API @@ -228,7 +235,7 @@ export default { "id": 'HEARING_MAP_API', }, // constants - "accessibilityColors": { + "accessibilityColors": { "default": "#2242C7", "missingInfo": "#4A4A4A", "shortcomings": "#b00021", @@ -306,5 +313,8 @@ export default { "matomoNoResultsDimensionID": settings.MATOMO_NO_RESULTS_DIMENSION_ID, "matomoUrl": settings.MATOMO_URL, "matomoSiteId": settings.MATOMO_SITE_ID, + "matomoEnabled": settings.MATOMO_ENABLED, "slowFetchMessageTimeout": Number(settings.SLOW_FETCH_MESSAGE_TIMEOUT) } + +export default defaultConfig; diff --git a/package-lock.json b/package-lock.json index 5fc728157..d22262578 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "servicemap-ui", "version": "0.1.0", "dependencies": { - "@datapunt/matomo-tracker-js": "^0.5.1", "@emotion/css": "^11.11.2", "@emotion/react": "^11.10.8", "@emotion/styled": "^11.10.8", @@ -73,6 +72,7 @@ "@emotion/server": "^11.10.0", "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^12.0.0", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.1.1", "axe-testcafe": "^1.1.0", "css-loader": "^7.1.1", @@ -2367,11 +2367,6 @@ "postcss-selector-parser": "^6.0.10" } }, - "node_modules/@datapunt/matomo-tracker-js": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@datapunt/matomo-tracker-js/-/matomo-tracker-js-0.5.1.tgz", - "integrity": "sha512-9/MW9vt/BA5Db7tO6LqCeQKtuvBNjyq51faF3AzUmPMlYsJCnASIxcut3VqJKiribhUoey7aYbPIYuj9x4DLPA==" - }, "node_modules/@devexpress/bin-v8-flags-filter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@devexpress/bin-v8-flags-filter/-/bin-v8-flags-filter-1.3.0.tgz", @@ -5931,6 +5926,36 @@ "react-dom": "<18.0.0" } }, + "node_modules/@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-test-renderer": "^16.9.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, "node_modules/@testing-library/user-event": { "version": "14.5.2", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", @@ -19988,6 +20013,22 @@ "react": "^18.3.1" } }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-error-overlay": { "version": "6.0.11", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", diff --git a/package.json b/package.json index 137e76553..eb78b35c2 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "version": "0.1.0", "private": true, "dependencies": { - "@datapunt/matomo-tracker-js": "^0.5.1", "@emotion/css": "^11.11.2", "@emotion/react": "^11.10.8", "@emotion/styled": "^11.10.8", @@ -88,6 +87,7 @@ "@emotion/server": "^11.10.0", "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^12.0.0", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.1.1", "axe-testcafe": "^1.1.0", "css-loader": "^7.1.1", diff --git a/server/server.js b/server/server.js index 0864a8bce..e1d8de221 100644 --- a/server/server.js +++ b/server/server.js @@ -217,6 +217,7 @@ const htmlTemplate = (req, reactDom, preloadedState, css, cssString, emotionCss, window.nodeEnvSettings.MATOMO_NO_RESULTS_DIMENSION_ID = "${process.env.MATOMO_NO_RESULTS_DIMENSION_ID}"; window.nodeEnvSettings.MATOMO_URL = "${process.env.MATOMO_URL}"; window.nodeEnvSettings.MATOMO_SITE_ID = "${process.env.MATOMO_SITE_ID}"; + window.nodeEnvSettings.MATOMO_ENABLED = "${process.env.MATOMO_ENABLED}"; window.nodeEnvSettings.MODE = "${process.env.MODE}"; window.nodeEnvSettings.INITIAL_MAP_POSITION = "${customValues.initialMapPosition}"; window.nodeEnvSettings.SERVICE_MAP_URL = "${process.env.SERVICE_MAP_URL}"; diff --git a/src/App.js b/src/App.js index 770389a27..9cd890af6 100755 --- a/src/App.js +++ b/src/App.js @@ -16,15 +16,16 @@ import { StyledEngineProvider } from '@mui/material'; import hdsStyle from 'hds-design-tokens'; import withStyles from 'isomorphic-style-loader/withStyles'; import PropTypes from 'prop-types'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { Helmet } from 'react-helmet'; import { IntlProvider, useIntl } from 'react-intl'; import { useSelector } from 'react-redux'; -import { BrowserRouter, Route, Switch } from 'react-router-dom'; +import { + BrowserRouter, Route, Switch, useLocation, +} from 'react-router-dom'; import appStyles from './App.css'; import ogImage from './assets/images/servicemap-meta-img.png'; import { DataFetcher, Navigator } from './components'; -import SMCookies from './components/SMCookies/SMCookies'; import HSLFonts from './hsl-icons.css'; import styles from './index.css'; import DefaultLayout from './layouts'; @@ -38,6 +39,14 @@ import LocaleUtility from './utils/locale'; import EmbedderView from './views/EmbedderView'; import useMobileStatus from './utils/isMobile'; import { COOKIE_MODAL_ROOT_ID } from './utils/constants'; +import MatomoTracker from './components/Matomo/MatomoTracker'; +import MatomoContext from './components/Matomo/matomo-context'; +import config from '../config'; +import useMatomo from './components/Matomo/hooks/useMatomo'; +import { selectMobility, selectSenses } from './redux/selectors/settings'; +import SMCookies from './components/SMCookies/SMCookies'; +import { isEmbed } from './utils/path'; +import { servicemapTrackPageView } from './utils/tracking'; // General meta tags for app function MetaTags() { @@ -59,6 +68,10 @@ function MetaTags() { function App() { const locale = useSelector(getLocale); const intlData = LocaleUtility.intlData(locale); + const { trackPageView } = useMatomo(); + const location = useLocation(); + const senses = useSelector(selectSenses); + const mobility = useSelector(selectMobility); // Remove the server-side injected CSS. useEffect(() => { @@ -69,6 +82,23 @@ function App() { }, []); const isMobile = useMobileStatus(); + useEffect(() => { + // Simple custom servicemap page view tracking + servicemapTrackPageView(); + + if (!isEmbed()) { + trackPageView({ + href: window.location.href, + ...config.matomoMobilityDimensionID && config.matomoSensesDimensionID && ({ + customDimensions: [ + { id: config.matomoMobilityDimensionID, value: mobility || '' }, + { id: config.matomoSensesDimensionID, value: senses?.join(',') }, + ], + }), + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.pathname, location.search, mobility, senses]); return ( @@ -108,13 +138,33 @@ function App() { // Wrapper to get language route function LanguageWrapper() { + const matomoTracker = useMemo(() => { + if (config.matomoUrl && config.matomoSiteId && config.matomoEnabled) { + return new MatomoTracker({ + urlBase: `//${config.matomoUrl}/`, + siteId: config.matomoSiteId, + trackerUrl: 'tracker.php', + srcUrl: 'piwik.min.js', + enabled: config.matomoEnabled === 'true' && !isEmbed(), + linkTracking: false, + configurations: { + requireCookieConsent: undefined, + }, + }); + } + + return null; + }, []); + if (isClient()) { return ( - - - - - + + + + + + + ); } diff --git a/src/components/Matomo/MatomoTracker.js b/src/components/Matomo/MatomoTracker.js new file mode 100644 index 000000000..4a63025cf --- /dev/null +++ b/src/components/Matomo/MatomoTracker.js @@ -0,0 +1,112 @@ +/* eslint-disable no-underscore-dangle */ +import { TRACK_TYPES } from './constants'; + +class MatomoTracker { + constructor(userOptions) { + if (!userOptions.urlBase) { + throw new Error('Matomo urlBase is required'); + } + + if (!userOptions.siteId) { + throw new Error('Matomo siteId is required.'); + } + + this.initialize(userOptions); + } + + initialize({ + urlBase, + siteId, + srcUrl, + trackerUrl = 'matomo.php', + enabled = true, + linkTracking = true, + configurations = {}, + }) { + if (typeof window === 'undefined') { + return; + } + + window._paq = window._paq || []; + + if (window._paq.length !== 0) { + return; + } + + if (!enabled) { + return; + } + + this.pushInstruction('setTrackerUrl', `${urlBase}${trackerUrl}`); + this.pushInstruction('setSiteId', siteId); + + Object.entries(configurations).forEach(([name, instructions]) => { + if (instructions instanceof Array) { + this.pushInstruction(name, ...instructions); + } else if (instructions === undefined) { + this.pushInstruction(name); + } else { + this.pushInstruction(name, instructions); + } + }); + + this.enableLinkTracking(linkTracking); + + const doc = document; + const scriptElement = doc.createElement('script'); + const scripts = doc.getElementsByTagName('script')[0]; + + scriptElement.type = 'text/javascript'; + scriptElement.async = true; + scriptElement.defer = true; + scriptElement.src = `${urlBase}${srcUrl}`; + + if (scripts?.parentNode) { + scripts?.parentNode.insertBefore(scriptElement, scripts); + } + } + + enableLinkTracking(active) { + this.pushInstruction('enableLinkTracking', active); + } + + pushInstruction(name, ...args) { + if (typeof window !== 'undefined') { + window._paq.push([name, ...args]); + } + + return this; + } + + trackPageView(params) { + this.track({ data: [TRACK_TYPES.TRACK_VIEW], ...params }); + } + + track({ + data = [], + documentTitle = document.title, + href, + customDimensions = false, + }) { + if (data.length) { + if ( + customDimensions + && Array.isArray(customDimensions) + && customDimensions.length + ) { + customDimensions.map(customDimension => this.pushInstruction( + 'setCustomDimension', + customDimension.id, + customDimension.value, + )); + } + + this.pushInstruction('setCustomUrl', href ?? window.location.href); + this.pushInstruction('setDocumentTitle', documentTitle); + + this.pushInstruction(...(data)); + } + } +} + +export default MatomoTracker; diff --git a/src/components/Matomo/__tests__/MatomoTracker.test.js b/src/components/Matomo/__tests__/MatomoTracker.test.js new file mode 100644 index 000000000..2a058860f --- /dev/null +++ b/src/components/Matomo/__tests__/MatomoTracker.test.js @@ -0,0 +1,96 @@ +/* eslint-disable no-underscore-dangle */ +import MatomoTracker from '../MatomoTracker'; +import { TRACK_TYPES } from '../constants'; + +const MOCK_URL_BASE = 'https://www.test.fi/'; +const MOCK_TRACKER_URL = 'https://www.test.fi/matomo.php'; + +describe('MatomoTracker', () => { + beforeEach(() => { + window._paq = []; + }); + + it('should initialise window._paq', () => { + // eslint-disable-next-line no-new + new MatomoTracker({ + urlBase: MOCK_URL_BASE, + siteId: 'test123', + srcUrl: 'test.js', + enabled: true, + configurations: { + foo: 'bar', + testArray: ['testArrayItem1', 'testArrayItem2'], + testNoValue: undefined, + }, + }); + + expect(window._paq).toEqual([ + ['setTrackerUrl', MOCK_TRACKER_URL], + ['setSiteId', 'test123'], + ['foo', 'bar'], + ['testArray', 'testArrayItem1', 'testArrayItem2'], + ['testNoValue'], + ['enableLinkTracking', true], + ]); + }); + + it('should throw error if urlBase missing', () => { + expect( + () => new MatomoTracker({ siteId: 'test123' }), + ).toThrowError(); + }); + + it('should throw error if siteId missing', () => { + expect( + () => new MatomoTracker({ + urlBase: 'http://www.test.fi', + }), + ).toThrowError(); + }); + + it('should track page view', () => { + const tracker = new MatomoTracker({ + urlBase: MOCK_URL_BASE, + siteId: 'test123', + srcUrl: 'test.js', + enabled: true, + configurations: {}, + }); + + tracker.trackPageView(); + + expect(window._paq).toEqual([ + ['setTrackerUrl', MOCK_TRACKER_URL], + ['setSiteId', 'test123'], + ['enableLinkTracking', true], + ['setCustomUrl', window.location.href], + ['setDocumentTitle', ''], + [TRACK_TYPES.TRACK_VIEW], + ]); + }); + + it('should track custom event', () => { + const tracker = new MatomoTracker({ + urlBase: MOCK_URL_BASE, + siteId: 'test123', + srcUrl: 'test.js', + enabled: true, + configurations: {}, + }); + + tracker.track({ + data: ['event', 'click', 'button'], + documentTitle: 'Custom Event', + href: 'https://www.test.fi/custom-event', + }); + + expect(window._paq).toEqual([ + ['setTrackerUrl', MOCK_TRACKER_URL], + ['setSiteId', 'test123'], + ['enableLinkTracking', true], + ['setCustomUrl', 'https://www.test.fi/custom-event'], + ['setDocumentTitle', 'Custom Event'], + ['event', 'click', 'button'], + ]); + }); +}); diff --git a/src/components/Matomo/__tests__/matomo-context.test.js b/src/components/Matomo/__tests__/matomo-context.test.js new file mode 100644 index 000000000..a3f16667e --- /dev/null +++ b/src/components/Matomo/__tests__/matomo-context.test.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { MatomoProvider } from '../matomo-context'; + +describe('matomo-context', () => { + it('renders children with provided value', () => { + const value = 'test value'; + + const { getByText } = render( + +
Test Component
+
, + ); + + expect(getByText('Test Component')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Matomo/constants.js b/src/components/Matomo/constants.js new file mode 100644 index 000000000..8583a0a5c --- /dev/null +++ b/src/components/Matomo/constants.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export +export const TRACK_TYPES = { + TRACK_VIEW: 'trackPageView', +}; diff --git a/src/components/Matomo/hooks/__tests__/useMatomo.test.js b/src/components/Matomo/hooks/__tests__/useMatomo.test.js new file mode 100644 index 000000000..6faf14af5 --- /dev/null +++ b/src/components/Matomo/hooks/__tests__/useMatomo.test.js @@ -0,0 +1,68 @@ +import React, { useEffect } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { render } from '@testing-library/react'; + +import MatomoContext, { MatomoProvider } from '../../matomo-context'; +import * as MatomoTracker from '../../MatomoTracker'; +import useMatomo from '../useMatomo'; + +const MOCK_URL = 'https://www.hel.fi'; + +describe('useMatomo', () => { + it('should return trackPageView function', () => { + const trackPageView = jest.fn(); + const instance = { trackPageView }; + + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useMatomo(), { wrapper }); + + expect(result.current.trackPageView).toBeDefined(); + }); + + function MockedComponent() { + const { trackPageView } = useMatomo(); + + useEffect(() => { + trackPageView({ href: MOCK_URL }); + }, [trackPageView]); + + return
MockedComponent
; + } + + it('should trackPageView', () => { + const trackPageViewMock = jest.fn(); + + jest.spyOn(MatomoTracker, 'default').mockImplementation(() => ({ + trackPageView: trackPageViewMock, + })); + + // eslint-disable-next-line new-cap + const instance = new MatomoTracker.default({ + urlBase: MOCK_URL, + siteId: 'test123', + srcUrl: 'test.js', + enabled: true, + }); + + function MockProvider() { + return ( + + + + ); + } + + expect(MatomoTracker.default).toHaveBeenCalled(); + + render(); + + expect(trackPageViewMock).toHaveBeenCalledWith({ + href: MOCK_URL, + }); + }); +}); diff --git a/src/components/Matomo/hooks/useMatomo.js b/src/components/Matomo/hooks/useMatomo.js new file mode 100644 index 000000000..e7310142a --- /dev/null +++ b/src/components/Matomo/hooks/useMatomo.js @@ -0,0 +1,16 @@ +import { useCallback, useContext } from 'react'; + +import MatomoContext from '../matomo-context'; + +function useMatomo() { + const instance = useContext(MatomoContext); + + const trackPageView = useCallback( + params => instance?.trackPageView(params), + [instance], + ); + + return { trackPageView }; +} + +export default useMatomo; diff --git a/src/components/Matomo/matomo-context.js b/src/components/Matomo/matomo-context.js new file mode 100644 index 000000000..e03be7abf --- /dev/null +++ b/src/components/Matomo/matomo-context.js @@ -0,0 +1,18 @@ +/* eslint-disable react/forbid-prop-types */ +import React, { createContext } from 'react'; +import PropTypes from 'prop-types'; + +const MatomoContext = createContext(null); + +export function MatomoProvider({ children, value }) { + const Context = MatomoContext; + + return {children}; +} + +MatomoProvider.propTypes = { + children: PropTypes.node.isRequired, + value: PropTypes.object.isRequired, +}; + +export default MatomoContext; diff --git a/src/components/Navigator/Navigator.js b/src/components/Navigator/Navigator.js index 86c0fc204..08e954173 100644 --- a/src/components/Navigator/Navigator.js +++ b/src/components/Navigator/Navigator.js @@ -1,33 +1,11 @@ +/* eslint-disable react/forbid-prop-types */ import PropTypes from 'prop-types'; import React from 'react'; import { connect } from 'react-redux'; -import config from '../../../config'; import { breadcrumbPop, breadcrumbPush, breadcrumbReplace } from '../../redux/actions/breadcrumb'; -import { selectBreadcrumb, selectTracker } from '../../redux/selectors/general'; +import { selectBreadcrumb } from '../../redux/selectors/general'; import { selectResultsPreviousSearch } from '../../redux/selectors/results'; -import { selectMobility, selectSenses } from '../../redux/selectors/settings'; import { generatePath, isEmbed } from '../../utils/path'; -import { servicemapTrackPageView } from '../../utils/tracking'; - -const getHelsinkiCookie = () => { - const pairs = document.cookie.split(';'); - const cookies = {}; - pairs.forEach(item => { - const pair = item.split('='); - const key = (`${pair[0]}`).trim(); - const string = pair.slice(1).join('='); - cookies[key] = decodeURIComponent(string); - }); - const helsinkiCookie = cookies?.['city-of-helsinki-cookie-consents']; - return helsinkiCookie ? JSON.parse(helsinkiCookie) : null; -}; - -const shouldSentAnalytics = () => { - if (typeof window === 'undefined' || isEmbed()) { - return false; - } - return getHelsinkiCookie()?.matomo; -}; class Navigator extends React.Component { unlisten = null; @@ -41,32 +19,27 @@ class Navigator extends React.Component { componentDidMount() { const { history, - mobility = null, - senses = null, } = this.props; this.prevPathName = history.location.pathname; - // Initial pageView tracking on first load - this.trackPageView({ mobility, senses }); + if (this.unlisten) { this.unlisten(); } // Add event listener to listen history changes and track new pages - this.unlisten = history.listen(this.historyCallBack(mobility, senses)); + this.unlisten = history.listen(this.historyCallBack()); } // We need to update history tracking event when settings change componentDidUpdate() { const { history, - mobility = null, - senses = null, } = this.props; if (this.unlisten) { this.unlisten(); } - this.unlisten = history.listen(this.historyCallBack(mobility, senses)); + this.unlisten = history.listen(this.historyCallBack()); } componentWillUnmount() { @@ -76,39 +49,6 @@ class Navigator extends React.Component { } } - trackPageView = ({ mobility, senses }) => { - const { tracker = null } = this.props; - - // Simple custom servicemap page view tracking - servicemapTrackPageView(); - if (tracker && shouldSentAnalytics()) { - setTimeout(() => { - tracker.trackPageView({ - documentTitle: document.title, - customDimensions: [ - { id: config.matomoMobilityDimensionID, value: mobility || '' }, - { id: config.matomoSensesDimensionID, value: senses?.join(',') }, - ], - }); - }, 400); - } - }; - - trackNoResultsPage = (noResultsQuery) => { - const { tracker = null } = this.props; - if (tracker && shouldSentAnalytics()) { - this.unlisten = null; - setTimeout(() => { - tracker.trackPageView({ - documentTitle: document.title, - customDimensions: [ - { id: config.matomoNoResultsDimensionID, value: noResultsQuery }, - ], - }); - }, 400); - } - }; - /** * Generate url based on path string and data * @param target - Key string for path config @@ -121,11 +61,15 @@ class Navigator extends React.Component { const locale = params && params.lng; const isEmbeddableView = target !== 'info' && target !== 'feedback'; - const embedValue = isEmbeddableView ? (typeof embed !== 'undefined' ? embed : isEmbed()) : false; - return generatePath(target, locale, data, embedValue); - } + let embedValue = false; + if (isEmbeddableView) { + embedValue = typeof embed !== 'undefined' ? embed : isEmbed(); + } + + return generatePath(target, locale, data, embedValue); + }; /** * Go back in history if breadcrumbs has values otherwise return to home view @@ -146,14 +90,14 @@ class Navigator extends React.Component { history.push(this.generatePath('home', null, false)); breadcrumbPush({ location }); } - } - + }; /** * Push current location to history * @param target - String key for path config or object for history location * @param data - Data for path used if target is path key */ + // eslint-disable-next-line react/no-unused-class-component-methods push = (target, data, focusTarget) => { const { breadcrumbPush, @@ -175,7 +119,7 @@ class Navigator extends React.Component { } catch (e) { console.warn('Warning:', e.message); } - } + }; /** * Replace current location in history @@ -198,9 +142,10 @@ class Navigator extends React.Component { } catch (e) { console.warn('Warning:', e.message); } - } + }; // Add map param to url + // eslint-disable-next-line react/no-unused-class-component-methods openMap = () => { const { history } = this.props; const url = new URL(window.location); @@ -209,10 +154,11 @@ class Navigator extends React.Component { // TODO: better way to normalize spaces in url const searchString = url.search.replace('+', ' '); history.push(url.pathname + searchString); - } + }; // Remove map param from url - closeMap = (replace) => { + // eslint-disable-next-line react/no-unused-class-component-methods + closeMap = replace => { const { history } = this.props; const url = new URL(window.location); @@ -222,43 +168,46 @@ class Navigator extends React.Component { } else { history.push(url.pathname + url.search); } - } + }; - closeFeedback = (unitID) => { + // eslint-disable-next-line react/no-unused-class-component-methods + closeFeedback = unitID => { const { breadcrumb } = this.props; if (unitID && !breadcrumb.length) { this.replace('unit', { id: unitID }); return; } this.goBack(); - } + }; + // eslint-disable-next-line react/no-unused-class-component-methods setParameter = (param, value) => { const { history } = this.props; const url = new URL(window.location); url.searchParams.set(param, value); history.replace(url.pathname + url.search); - } + }; - removeParameter = (param) => { + // eslint-disable-next-line react/no-unused-class-component-methods + removeParameter = param => { const { history } = this.props; const url = new URL(window.location); url.searchParams.delete(param); history.replace(url.pathname + url.search); - } + }; - historyCallBack(mobility, senses) { - return (a) => { + historyCallBack() { + return a => { if (this.prevPathName === a.pathname) { return; } this.prevPathName = a.pathname; - this.trackPageView({ mobility, senses }); }; } + // eslint-disable-next-line react/no-arrow-function-lifecycle render = () => null; } @@ -269,18 +218,12 @@ Navigator.propTypes = { history: PropTypes.objectOf(PropTypes.any).isRequired, location: PropTypes.objectOf(PropTypes.any).isRequired, match: PropTypes.objectOf(PropTypes.any).isRequired, - senses: PropTypes.arrayOf(PropTypes.string), - mobility: PropTypes.string, - tracker: PropTypes.objectOf(PropTypes.any), }; // Listen to redux state const mapStateToProps = state => ({ breadcrumb: selectBreadcrumb(state), previousSearch: selectResultsPreviousSearch(state), - mobility: selectMobility(state), - senses: selectSenses(state), - tracker: selectTracker(state), }); export default connect( diff --git a/src/components/SMCookies/SMCookies.js b/src/components/SMCookies/SMCookies.js index 3802b1267..cc83abf98 100644 --- a/src/components/SMCookies/SMCookies.js +++ b/src/components/SMCookies/SMCookies.js @@ -1,37 +1,50 @@ +/* eslint-disable no-underscore-dangle */ import { CookieModal } from 'hds-react'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; -import { useDispatch, useSelector } from 'react-redux'; -import { setTracker } from '../../redux/actions/tracker'; -import { selectTracker } from '../../redux/selectors/general'; +import { useSelector } from 'react-redux'; import { getLocale } from '../../redux/selectors/user'; import { COOKIE_MODAL_ROOT_ID } from '../../utils/constants'; import { isEmbed } from '../../utils/path'; -import { getMatomoTracker } from '../../utils/tracking'; import featureFlags from '../../../config/featureFlags'; function SMCookies() { const intl = useIntl(); const locale = useSelector(getLocale); - const tracker = useSelector(selectTracker); - const dispatch = useDispatch(); const cookieDomain = typeof window !== 'undefined' ? window.location.hostname : undefined; const embed = isEmbed(); + const [mounted, setMounted] = useState(false); - if (embed || !featureFlags.smCookies) { + useEffect(() => { + setMounted(true); + }, []); + + if (embed || !mounted || !featureFlags.smCookies) { // No cookie modal or tracking in embed mode return null; } - function parseConsentsAndActOnThem(consents) { - if (!tracker && consents.matomo) { - const matomoTracker = getMatomoTracker(); - if (matomoTracker) { - dispatch(setTracker(matomoTracker)); + function onAllConsentsGiven(consents) { + if (window._paq) { + if (consents.matomo) { + // start tracking + window._paq.push(['rememberCookieConsentGiven']); + window._paq.push(['rememberConsentGiven']); + } else { + // tell matomo to forget conset + window._paq.push(['forgetCookieConsentGiven']); + window._paq.push(['forgetConsentGiven']); } } } + function onConsentsParsed(consents) { + if (window._paq && consents.matomo === undefined) { + // tell matomo to wait for consent: + window._paq.push(['requireCookieConsent']); + } + } + const contentSource = { siteName: intl.formatMessage({ id: 'app.title' }), currentLanguage: locale, @@ -39,21 +52,19 @@ function SMCookies() { groups: [ { commonGroup: 'statistics', - cookies: [ - { - id: 'matomo', - name: '_pk*', - hostName: 'digia.fi', - description: intl.formatMessage({ id: 'cookies.matomo.description' }), - expiration: intl.formatMessage({ id: 'cookies.matomo.expiration' }, { days: 393 }), - }, - ], + cookies: [{ + id: 'matomo', + name: '_pk*', + hostName: 'digia.fi', + description: intl.formatMessage({ id: 'cookies.matomo.description' }), + expiration: intl.formatMessage({ id: 'cookies.matomo.expiration' }, { days: 393 }), + }], }, ], }, focusTargetSelector: '#app', - onAllConsentsGiven: consents => parseConsentsAndActOnThem(consents), - onConsentsParsed: consents => parseConsentsAndActOnThem(consents), + onAllConsentsGiven, + onConsentsParsed, }; return ( { if (featureFlags.servicemapPageTracking) { const smAPI = new ServiceMapAPI(); diff --git a/src/views/SearchView/SearchView.js b/src/views/SearchView/SearchView.js index af20f4176..335729653 100644 --- a/src/views/SearchView/SearchView.js +++ b/src/views/SearchView/SearchView.js @@ -1,19 +1,28 @@ /* eslint-disable camelcase */ import styled from '@emotion/styled'; -import { Divider, Link, NoSsr, Paper, Typography } from '@mui/material'; +import { + Divider, Link, NoSsr, Paper, Typography, +} from '@mui/material'; import { visuallyHidden } from '@mui/utils'; import React, { useEffect, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import { useDispatch, useSelector } from 'react-redux'; import { useLocation, useRouteMatch } from 'react-router-dom'; -import { AddressSearchBar, Container, Loading, SearchBar, SettingsComponent, TabLists } from '../../components'; +import { + AddressSearchBar, Container, Loading, SearchBar, SettingsComponent, TabLists, +} from '../../components'; import fetchSearchResults from '../../redux/actions/search'; -import { activateSetting, resetAccessibilitySettings, setCities, setMapType, setOrganizations } from '../../redux/actions/settings'; +import { + activateSetting, resetAccessibilitySettings, setCities, setMapType, setOrganizations, +} from '../../redux/actions/settings'; import { changeCustomUserLocation, resetCustomPosition } from '../../redux/actions/user'; import { selectBounds, selectMapRef, selectNavigator } from '../../redux/selectors/general'; import { getOrderedSearchResultData, selectResultsData, selectSearchResults } from '../../redux/selectors/results'; -import { selectMapType, selectSelectedAccessibilitySettings, selectSelectedCities, selectSelectedOrganizationIds } from '../../redux/selectors/settings'; +import { + // eslint-disable-next-line max-len + selectMapType, selectSelectedAccessibilitySettings, selectSelectedCities, selectSelectedOrganizationIds, +} from '../../redux/selectors/settings'; import { selectCustomPositionAddress } from '../../redux/selectors/user'; import { keyboardHandler, parseSearchParams } from '../../utils'; import { viewTitleID } from '../../utils/accessibility'; @@ -26,10 +35,12 @@ import optionsToSearchQuery from '../../utils/search'; import SettingsUtility from '../../utils/settings'; import fetchAddressData from '../AddressView/utils/fetchAddressData'; import { fitUnitsToMap } from '../MapView/utils/mapActions'; +import useMatomo from '../../components/Matomo/hooks/useMatomo'; +import config from '../../../config'; const focusClass = 'TabListFocusTarget'; -const SearchView = () => { +function SearchView() { const [analyticsSent, setAnalyticsSent] = useState(null); const orderedData = useSelector(getOrderedSearchResultData); const unorderedSearchResults = useSelector(selectResultsData); @@ -51,11 +62,13 @@ const SearchView = () => { const location = useLocation(); const match = useRouteMatch(); const intl = useIntl(); + const { trackPageView } = useMatomo(); + const searchResults = applyCityAndOrganizationFilter(orderedData, location, embed); const getResultsByType = type => searchResults.filter(item => item.object_type === type); - const stringifySearchQuery = (data) => { + const stringifySearchQuery = data => { try { const search = Object.keys(data).map(key => (`${key}:${data[key]}`)); return search.join(','); @@ -207,7 +220,7 @@ const SearchView = () => { return null; }; - const handleUserAddressChange = (address) => { + const handleUserAddressChange = address => { if (address) { dispatch(changeCustomUserLocation( [address.location.coordinates[1], address.location.coordinates[0]], @@ -287,6 +300,7 @@ const SearchView = () => { handleCityAndOrganisationSettings(municipality, city, organization); handleAccessibilityParams(accessibility_setting); handleAddressParam(hcity, hstreet); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -301,6 +315,7 @@ const SearchView = () => { } dispatch(fetchSearchResults(options)); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [match.params]); useEffect(() => { @@ -332,35 +347,45 @@ const SearchView = () => { && analyticsSent !== previousSearch ) { setAnalyticsSent(previousSearch); - navigator.trackNoResultsPage(previousSearch); + + if (!isEmbed()) { + trackPageView({ + href: window.location.href, + ...config.matomoMobilityDimensionID && ( + { id: config.matomoNoResultsDimensionID, value: previousSearch } + ), + }); + } } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(unorderedSearchResults), searchResults]); - useEffect(() => { - if (embed || !navigator) { - return; - } - navigator.setParameter('city', selectedCities); - navigator.setParameter('organization', selectedOrganizationIds); - navigator.setParameter('accessibility_setting', selectedAccessibilitySettings); - if (bounds) { - navigator.setParameter('bbox', getBboxFromBounds(bounds)); - } - if (customPositionAddress) { - const { municipality, name } = getAddressNavigatorParamsConnector(customPositionAddress); - navigator.setParameter('hcity', municipality); - navigator.setParameter('hstreet', name); - } else { - navigator.removeParameter('hcity'); - navigator.removeParameter('hstreet'); - } - navigator.setParameter('map', mapType); - }, - [ - navigator, embed, selectedCities, selectedOrganizationIds, selectedAccessibilitySettings, - bounds, customPositionAddress, mapType, - ], + useEffect( + () => { + if (embed || !navigator) { + return; + } + navigator.setParameter('city', selectedCities); + navigator.setParameter('organization', selectedOrganizationIds); + navigator.setParameter('accessibility_setting', selectedAccessibilitySettings); + if (bounds) { + navigator.setParameter('bbox', getBboxFromBounds(bounds)); + } + if (customPositionAddress) { + const { municipality, name } = getAddressNavigatorParamsConnector(customPositionAddress); + navigator.setParameter('hcity', municipality); + navigator.setParameter('hstreet', name); + } else { + navigator.removeParameter('hcity'); + navigator.removeParameter('hstreet'); + } + navigator.setParameter('map', mapType); + }, + [ + navigator, embed, selectedCities, selectedOrganizationIds, selectedAccessibilitySettings, + bounds, customPositionAddress, mapType, + ], ); const renderSearchBar = () => ( @@ -526,7 +551,7 @@ const SearchView = () => { {renderScreenReaderInfo()} {searchFetchState.isFetching ? ( - ) : renderResults() } + ) : renderResults()} {renderNotFound()} {isMobile ? ( // Jump link back to beginning of current page @@ -538,7 +563,7 @@ const SearchView = () => { ) : null} ); -}; +} export default SearchView;