diff --git a/package.json b/package.json index 5b690609..7b8cf327 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "bootstrap": "^4.1.1", "case-sensitive-paths-webpack-plugin": "2.1.1", "chalk": "1.1.3", + "chart.js": "^2.7.2", "css-loader": "0.28.7", "dotenv": "4.0.0", "dotenv-expand": "4.2.0", @@ -44,6 +45,7 @@ "raf": "3.4.0", "ramda": "^0.25.0", "react": "^16.3.2", + "react-chartjs-2": "^2.7.4", "react-copy-to-clipboard": "^5.0.1", "react-datepicker": "^1.5.0", "react-dev-utils": "^5.0.1", diff --git a/src/api/ShlinkApiClient.js b/src/api/ShlinkApiClient.js index 2539050b..e958b620 100644 --- a/src/api/ShlinkApiClient.js +++ b/src/api/ShlinkApiClient.js @@ -31,13 +31,13 @@ export class ShlinkApiClient { const filteredOptions = reject(value => isEmpty(value) || isNil(value), options); return this._performRequest('/short-codes', 'POST', {}, filteredOptions) .then(resp => resp.data) - .catch(e => this._handleAuthError(e, this.listShortUrls, [filteredOptions])); + .catch(e => this._handleAuthError(e, this.createShortUrl, [filteredOptions])); }; - getShortUrlVisits = shortCode => - this._performRequest(`/short-codes/${shortCode}/visits`, 'GET') + getShortUrlVisits = (shortCode, dates) => + this._performRequest(`/short-codes/${shortCode}/visits`, 'GET', dates) .then(resp => resp.data.visits.data) - .catch(e => this._handleAuthError(e, this.listShortUrls, [shortCode])); + .catch(e => this._handleAuthError(e, this.getShortUrlVisits, [shortCode, dates])); _performRequest = async (url, method = 'GET', params = {}, data = {}) => { if (isEmpty(this._token)) { diff --git a/src/reducers/index.js b/src/reducers/index.js index afa1c33a..6aae91c8 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -5,6 +5,7 @@ import selectedServerReducer from '../servers/reducers/selectedServer'; import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList'; import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams'; import shortUrlCreationResultReducer from '../short-urls/reducers/shortUrlCreationResult'; +import shortUrlVisitsReducer from '../short-urls/reducers/shortUrlVisits'; export default combineReducers({ servers: serversReducer, @@ -12,4 +13,5 @@ export default combineReducers({ shortUrlsList: shortUrlsListReducer, shortUrlsListParams: shortUrlsListParamsReducer, shortUrlCreationResult: shortUrlCreationResultReducer, + shortUrlVisits: shortUrlVisitsReducer }); diff --git a/src/short-urls/ShortUrlVisits.js b/src/short-urls/ShortUrlVisits.js index fbbaf082..c59e35ce 100644 --- a/src/short-urls/ShortUrlVisits.js +++ b/src/short-urls/ShortUrlVisits.js @@ -1,25 +1,68 @@ import React from 'react'; +import { Doughnut, HorizontalBar } from 'react-chartjs-2'; import { connect } from 'react-redux'; import { pick } from 'ramda'; +import { Card, CardBody, CardHeader } from 'reactstrap'; import { getShortUrlVisits } from './reducers/shortUrlVisits'; +import VisitsParser from '../visits/services/VisitsParser'; export class ShortUrlsVisits extends React.Component { + state = { startDate: '', endDate: '' }; + componentDidMount() { const { match: { params } } = this.props; - this.props.getShortUrlVisits(params.shortCode); + this.props.getShortUrlVisits(params.shortCode, this.state); } render() { - const { match: { params }, selectedServer } = this.props; + const { match: { params }, selectedServer, visitsParser, shortUrlVisits } = this.props; const serverUrl = selectedServer ? selectedServer.url : ''; const shortUrl = `${serverUrl}/${params.shortCode}`; + const generateGraphData = stats => ({ + labels: Object.keys(stats), + datasets: Object.values(stats) + }); return (
-
-
+ +

Visit stats for {shortUrl}

- {/* TODO Once Shlink's API allows it, add total visits counter, long URL and creation time */} +
+
+ +
+
+ + Operating systems + + + + +
+
+ + Browsers + + + + +
+
+ + Countries + + + + +
+
+ + Referrers + + + +
@@ -27,6 +70,10 @@ export class ShortUrlsVisits extends React.Component { } } -export default connect(pick(['selectedServer']), { +ShortUrlsVisits.defaultProps = { + visitsParser: VisitsParser +}; + +export default connect(pick(['selectedServer', 'shortUrlVisits']), { getShortUrlVisits })(ShortUrlsVisits); diff --git a/src/short-urls/reducers/shortUrlVisits.js b/src/short-urls/reducers/shortUrlVisits.js index a484a4db..df35e71a 100644 --- a/src/short-urls/reducers/shortUrlVisits.js +++ b/src/short-urls/reducers/shortUrlVisits.js @@ -34,11 +34,11 @@ export default function dispatch (state = initialState, action) { } } -export const getShortUrlVisits = shortCode => async dispatch => { +export const getShortUrlVisits = (shortCode, dates) => async dispatch => { dispatch({ type: GET_SHORT_URL_VISITS_START }); try { - const visits = await ShlinkApiClient.getShortUrlVisits(shortCode); + const visits = await ShlinkApiClient.getShortUrlVisits(shortCode, dates); dispatch({ visits, type: GET_SHORT_URL_VISITS }); } catch (e) { dispatch({ type: GET_SHORT_URL_VISITS_ERROR }); diff --git a/src/visits/services/VisitsParser.js b/src/visits/services/VisitsParser.js new file mode 100644 index 00000000..2d35a760 --- /dev/null +++ b/src/visits/services/VisitsParser.js @@ -0,0 +1,101 @@ +import { forEach, isNil, isEmpty } from 'ramda'; + +const osFromUserAgent = userAgent => { + const lowerUserAgent = userAgent.toLowerCase(); + + switch (true) { + case (lowerUserAgent.indexOf('linux') >= 0): + return 'Linux'; + case (lowerUserAgent.indexOf('windows') >= 0): + return 'Windows'; + case (lowerUserAgent.indexOf('mac') >= 0): + return 'MacOS'; + case (lowerUserAgent.indexOf('mobi') >= 0): + return 'Mobile'; + default: + return 'Others'; + } +}; + +const browserFromUserAgent = userAgent => { + const lowerUserAgent = userAgent.toLowerCase(); + + switch (true) { + case (lowerUserAgent.indexOf('firefox') >= 0): + return 'Firefox'; + case (lowerUserAgent.indexOf('chrome') >= 0): + return 'Chrome'; + case (lowerUserAgent.indexOf('safari') >= 0): + return 'Safari'; + case (lowerUserAgent.indexOf('opera') >= 0): + return 'Opera'; + case (lowerUserAgent.indexOf('msie') >= 0): + return 'Internet Explorer'; + default: + return 'Others'; + } +}; + +const extractDomain = url => { + const domain = url.indexOf('://') > -1 ? url.split('/')[2] : url.split('/')[0]; + return domain.split(':')[0]; +}; + +// FIXME Refactor these foreach statements which mutate a stats object +export class VisitsParser { + processOsStats = visits => { + const stats = {}; + + forEach(visit => { + const userAgent = visit.userAgent; + const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent); + + stats[os] = typeof stats[os] === 'undefined' ? 1 : stats[os] + 1; + }, visits); + + return stats; + }; + + processBrowserStats = visits => { + const stats = {}; + + forEach(visit => { + const userAgent = visit.userAgent; + const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent); + + stats[browser] = typeof stats[browser] === 'undefined' ? 1 : stats[browser] + 1; + }, visits); + + return stats; + }; + + processReferrersStats = visits => { + const stats = {}; + + forEach(visit => { + const notHasDomain = isNil(visit.referer) || isEmpty(visit.referer); + const domain = notHasDomain ? 'Unknown' : extractDomain(visit.referer); + + stats[domain] = typeof stats[domain] === 'undefined' ? 1 : stats[domain] + 1; + }, visits); + + return stats; + }; + + processCountriesStats = visits => { + const stats = {}; + + forEach(({ visitLocation }) => { + const notHasCountry = isNil(visitLocation) + || isNil(visitLocation.countryName) + || isEmpty(visitLocation.countryName); + const country = notHasCountry ? 'Unknown' : visitLocation.countryName; + + stats[country] = typeof stats[country] === 'undefined' ? 1 : stats[country] + 1; + }, visits); + + return stats; + }; +} + +export default new VisitsParser(); diff --git a/yarn.lock b/yarn.lock index 1d32185d..27e6da93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1437,6 +1437,26 @@ chardet@^0.4.0: version "0.4.2" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2" +chart.js@^2.7.2: + version "2.7.2" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.7.2.tgz#3c9fde4dc5b95608211bdefeda7e5d33dffa5714" + dependencies: + chartjs-color "^2.1.0" + moment "^2.10.2" + +chartjs-color-string@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.5.0.tgz#8d3752d8581d86687c35bfe2cb80ac5213ceb8c1" + dependencies: + color-name "^1.0.0" + +chartjs-color@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.2.0.tgz#84a2fb755787ed85c39dd6dd8c7b1d88429baeae" + dependencies: + chartjs-color-string "^0.5.0" + color-convert "^0.5.3" + cheerio@^1.0.0-rc.2: version "1.0.0-rc.2" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.2.tgz#4b9f53a81b27e4d5dac31c0ffd0cfa03cc6830db" @@ -1589,6 +1609,10 @@ collection-visit@^1.0.0: map-visit "^1.0.0" object-visit "^1.0.0" +color-convert@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd" + color-convert@^1.3.0, color-convert@^1.9.0: version "1.9.2" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.2.tgz#49881b8fba67df12a96bdf3f56c0aab9e7913147" @@ -4905,7 +4929,7 @@ mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdi dependencies: minimist "0.0.8" -moment@^2.22.2: +moment@^2.10.2, moment@^2.22.2: version "2.22.2" resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" @@ -6070,6 +6094,13 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.1.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-chartjs-2@^2.7.4: + version "2.7.4" + resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-2.7.4.tgz#e41ea4e81491dc78347111126a48e96ee57db1a6" + dependencies: + lodash "^4.17.4" + prop-types "^15.5.8" + react-copy-to-clipboard@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.1.tgz#8eae107bb400be73132ed3b6a7b4fb156090208e"