From 5381b0660ceb862c5516fb4d1c260fa99827d860 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Thu, 21 Jul 2016 16:35:37 -0700 Subject: [PATCH] Feature(analytics): Add redux logic for analytics Add(nav): Add event tracking to nav bar Add(Drawer): Add event tracking to chat/map drawer --- client/index.js | 5 +- client/sagas/analytics-saga.js | 43 +++++++++++++ client/sagas/index.js | 4 +- client/utils/send-page-analytics.js | 6 ++ client/{ => utils}/use-lang-routes.js | 2 +- common/app/App.jsx | 15 +++-- common/app/components/Nav/Nav.jsx | 39 +++++++++++- common/app/components/Nav/links.json | 3 +- common/app/redux/actions.js | 91 +++++++++++++++++++++++++-- common/app/redux/types.js | 1 + 10 files changed, 192 insertions(+), 17 deletions(-) create mode 100644 client/sagas/analytics-saga.js create mode 100644 client/utils/send-page-analytics.js rename client/{ => utils}/use-lang-routes.js (93%) 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 (
  • e.preventDefault()} + onClick={ this.handleMapClickOnMap } > Map @@ -108,6 +141,7 @@ export default class extends React.Component { { content } diff --git a/common/app/components/Nav/links.json b/common/app/components/Nav/links.json index d3bf655b54..d74e7d6fd2 100644 --- a/common/app/components/Nav/links.json +++ b/common/app/components/Nav/links.json @@ -4,7 +4,8 @@ "target": "_blank" },{ "content": "About", - "link": "/about" + "link": "/about", + "target": "_blank" },{ "content": "Shop", "link": "/shop" diff --git a/common/app/redux/actions.js b/common/app/redux/actions.js index 6593354c3b..8fa279ad53 100644 --- a/common/app/redux/actions.js +++ b/common/app/redux/actions.js @@ -2,6 +2,47 @@ import { Observable } from 'rx'; import { createAction } from 'redux-actions'; import types from './types'; +const throwIfUndefined = () => { + throw new TypeError('Argument must not be of type `undefined`'); +}; + +export const createEventMeta = ({ + category = throwIfUndefined, + action = throwIfUndefined, + label, + value +} = throwIfUndefined) => ({ + analytics: { + type: 'event', + category, + action, + label, + value + } +}); + +export const trackEvent = createAction( + types.analytics, + null, + createEventMeta +); + +export const trackSocial = createAction( + types.analytics, + null, + ( + network = throwIfUndefined, + action = throwIfUndefined, + target = throwIfUndefined + ) => ({ + analytics: { + type: 'event', + network, + action, + target + } + }) +); // updateTitle(title: String) => Action export const updateTitle = createAction(types.updateTitle); @@ -79,10 +120,50 @@ export const doActionOnError = actionCreator => error => Observable.of( // drawers -export const toggleMapDrawer = createAction(types.toggleMapDrawer); -export const toggleMainChat = createAction(types.toggleMainChat); -export const toggleHelpChat = createAction(types.toggleHelpChat); -export const openHelpChat = createAction(types.openHelpChat); -export const closeHelpChat = createAction(types.closeHelpChat); +export const toggleMapDrawer = createAction( + types.toggleMapDrawer, + null, + () => createEventMeta({ + category: 'Nav', + action: 'toggled', + label: 'Map drawer toggled' + }) +); +export const toggleMainChat = createAction( + types.toggleMainChat, + null, + () => createEventMeta({ + category: 'Nav', + action: 'toggled', + label: 'Main chat toggled' + }) +); +export const toggleHelpChat = createAction( + types.toggleHelpChat, + null, + () => createEventMeta({ + category: 'Challenge', + action: 'toggled', + label: 'help chat toggled' + }) +); +export const openHelpChat = createAction( + types.openHelpChat, + null, + () => createEventMeta({ + category: 'Challenge', + action: 'opened', + label: 'help chat opened' + }) +); +export const closeHelpChat = createAction( + types.closeHelpChat, + null, + () => createEventMeta({ + category: 'Challenge', + action: 'closed', + label: 'help chat closed' + }) +); export const toggleNightMode = createAction(types.toggleNightMode); diff --git a/common/app/redux/types.js b/common/app/redux/types.js index 26789493b8..db5891992f 100644 --- a/common/app/redux/types.js +++ b/common/app/redux/types.js @@ -1,6 +1,7 @@ import createTypes from '../utils/create-types'; export default createTypes([ + 'analytics', 'updateTitle', 'updateAppLang',