diff --git a/client/index.js b/client/index.js index 047b84d930..e8e3ce0bd1 100644 --- a/client/index.js +++ b/client/index.js @@ -10,7 +10,8 @@ import { } from 'react-router-redux'; import { render } from 'redux-epic'; import { createHistory } from 'history'; -import useLangRoutes from './use-lang-routes.js'; +import useLangRoutes from './utils/use-lang-routes'; +import sendPageAnalytics from './utils/send-page-analytics.js'; import createApp from '../common/app'; import provideStore from '../common/app/provide-store'; @@ -37,6 +38,7 @@ initialState.app.csrfToken = csrfToken; const serviceOptions = { xhrPath: '/services', context: { _csrf: csrfToken } }; const history = useLangRoutes(createHistory)(); +sendPageAnalytics(history, window.ga); const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f; const adjustUrlOnReplay = !!window.devToolsExtension; @@ -49,7 +51,6 @@ const sagaOptions = { history: window.history }; - createApp({ history, syncHistoryWithStore, diff --git a/client/sagas/analytics-saga.js b/client/sagas/analytics-saga.js new file mode 100644 index 0000000000..9df6fcbf99 --- /dev/null +++ b/client/sagas/analytics-saga.js @@ -0,0 +1,43 @@ +import { Observable } from 'rx'; +import { createErrorObservable } from '../../common/app/redux/actions'; +import capitalize from 'lodash/capitalize'; + +// analytics types +// interface social { +// network: String, // facebook, twitter, etc +// action: String, // like, favorite, etc +// target: String // url like fcc.com or any other string +// } +// interface event { +// category: String, +// action: String, +// label?: String, +// value?: String +// } +// +const types = [ 'event', 'social' ]; +function formatFields({ type, ...fields }) { + // make sure type is supported + if (!types.some(_type => _type === type)) { + return null; + } + return Object.keys(fields).reduce((_fields, field) => { + _fields[ type + capitalize(field) ] = fields[ field ]; + return _fields; + }, { type }); +} + +export default function analyticsSaga(actions, getState, { window }) { + const { ga } = window; + if (typeof ga !== 'function') { + console.log('GA not found'); + return Observable.empty(); + } + return actions + .filter(({ meta }) => !!(meta && meta.analytics && meta.analytics.type)) + .map(({ meta: { analytics } }) => formatFields(analytics)) + .filter(Boolean) + // ga always returns undefined + .map(({ type, ...fields }) => ga('send', type, fields)) + .catch(createErrorObservable); +} diff --git a/client/sagas/index.js b/client/sagas/index.js index d9fa142999..ec4074af58 100644 --- a/client/sagas/index.js +++ b/client/sagas/index.js @@ -7,6 +7,7 @@ import frameSaga from './frame-saga'; import codeStorageSaga from './code-storage-saga'; import gitterSaga from './gitter-saga'; import mouseTrapSaga from './mouse-trap-saga'; +import analyticsSaga from './analytics-saga'; export default [ errSaga, @@ -17,5 +18,6 @@ export default [ frameSaga, codeStorageSaga, gitterSaga, - mouseTrapSaga + mouseTrapSaga, + analyticsSaga ]; diff --git a/client/utils/send-page-analytics.js b/client/utils/send-page-analytics.js new file mode 100644 index 0000000000..8528a7b50c --- /dev/null +++ b/client/utils/send-page-analytics.js @@ -0,0 +1,6 @@ +export default function sendPageAnalytics(history, ga) { + history.listen(location => { + ga('set', 'page', location.pathname + location.search); + ga('send', 'pageview'); + }); +} diff --git a/client/use-lang-routes.js b/client/utils/use-lang-routes.js similarity index 93% rename from client/use-lang-routes.js rename to client/utils/use-lang-routes.js index d18921521b..c6fd2a3384 100644 --- a/client/use-lang-routes.js +++ b/client/utils/use-lang-routes.js @@ -1,4 +1,4 @@ -import { addLang, getLangFromPath } from '../common/app/utils/lang.js'; +import { addLang, getLangFromPath } from '../../common/app/utils/lang.js'; function addLangToLocation(location, lang) { if (!location) { diff --git a/common/app/App.jsx b/common/app/App.jsx index 36983813e2..28ec870e0c 100644 --- a/common/app/App.jsx +++ b/common/app/App.jsx @@ -10,7 +10,8 @@ import { updateNavHeight, toggleMapDrawer, toggleMainChat, - updateAppLang + updateAppLang, + trackEvent } from './redux/actions'; import { submitChallenge } from './routes/challenges/redux/actions'; @@ -26,7 +27,8 @@ const bindableActions = { submitChallenge, toggleMapDrawer, toggleMainChat, - updateAppLang + updateAppLang, + trackEvent }; const mapStateToProps = createSelector( @@ -77,7 +79,8 @@ export class FreeCodeCamp extends React.Component { fetchUser: PropTypes.func, shouldShowSignIn: PropTypes.bool, params: PropTypes.object, - updateAppLang: PropTypes.func.isRequired + updateAppLang: PropTypes.func.isRequired, + trackEvent: PropTypes.func.isRequired }; componentWillReceiveProps(nextProps) { @@ -120,7 +123,8 @@ export class FreeCodeCamp extends React.Component { toggleMapDrawer, toggleMainChat, shouldShowSignIn, - params: { lang } + params: { lang }, + trackEvent } = this.props; const navProps = { isOnMap: router.isActive(`/${lang}/map`), @@ -130,7 +134,8 @@ export class FreeCodeCamp extends React.Component { updateNavHeight, toggleMapDrawer, toggleMainChat, - shouldShowSignIn + shouldShowSignIn, + trackEvent }; return ( diff --git a/common/app/components/Nav/Nav.jsx b/common/app/components/Nav/Nav.jsx index 875cdcf780..7f255d1666 100644 --- a/common/app/components/Nav/Nav.jsx +++ b/common/app/components/Nav/Nav.jsx @@ -31,7 +31,22 @@ const toggleButtonChild = ( ); +function handleNavLinkEvent(content) { + this.props.trackEvent({ + category: 'Nav', + action: 'clicked', + label: `${content} link` + }); +} + export default class extends React.Component { + constructor(...props) { + super(...props); + this.handleMapClickOnMap = this.handleMapClickOnMap.bind(this); + navLinks.forEach(({ content }) => { + this[`handle${content}Click`] = handleNavLinkEvent.bind(this, content); + }); + } static displayName = 'Nav'; static propTypes = { points: PropTypes.number, @@ -42,7 +57,8 @@ export default class extends React.Component { updateNavHeight: PropTypes.func, toggleMapDrawer: PropTypes.func, toggleMainChat: PropTypes.func, - shouldShowSignIn: PropTypes.bool + shouldShowSignIn: PropTypes.bool, + trackEvent: PropTypes.func.isRequired }; componentDidMount() { @@ -50,13 +66,30 @@ export default class extends React.Component { this.props.updateNavHeight(navBar.clientHeight); } + handleMapClickOnMap(e) { + e.preventDefault(); + this.props.trackEvent({ + category: 'Nav', + action: 'clicked', + label: 'map clicked while on map' + }); + } + + handleNavClick() { + this.props.trackEvent({ + category: 'Nav', + action: 'clicked', + label: 'map clicked while on map' + }); + } + renderMapLink(isOnMap, toggleMapDrawer) { if (isOnMap) { return (